Back to blog
SequencesOnboardingTutorialTypeScriptSaaS

How to Build an Onboarding Email Sequence (With Code)

Step-by-step guide to building a SaaS onboarding email sequence using an API. 5-email flow with delays, branching, and personalization — complete TypeScript examples.

SendPigeon TeamApril 21, 20268 min read

A good onboarding sequence turns signups into active users. This guide builds one from scratch using the SendPigeon sequences API — 5 emails over 7 days with delays, branching, and personalization.

TL;DR

The sequence:

  1. Welcome email (immediate)
  2. Quick start guide (Day 1)
  3. Branch: activated? → tips or nudge
  4. Feature highlight (Day 5)
  5. Check-in (Day 7)

Built entirely in code. Version-controlled, testable, deployable.


The Plan

Day 0: Welcome email
  ↓ (wait 1 day)
Day 1: Quick start guide
  ↓ (wait 2 days)
Day 3: Branch — has user activated?
  ├─ Yes → Power user tips
  └─ No  → Nudge + offer help
  ↓ (wait 2 days)
Day 5: Feature highlight
  ↓ (wait 2 days)
Day 7: Check-in + tag as onboarded

Prerequisites

npm install sendpigeon
import { SendPigeon } from "sendpigeon";

const pigeon = new SendPigeon(process.env.SENDPIGEON_API_KEY!);

You'll need email templates created in the SendPigeon dashboard or via API. We'll reference them by ID.


Step 1: Create the Sequence

const sequence = await pigeon.sequences.create({
  name: "SaaS Onboarding",
  description: "7-day onboarding for new signups",
  triggerType: "CONTACT_CREATED",
});

const seqId = sequence.id;
console.log(`Created sequence: ${seqId}`);

Using CONTACT_CREATED as the trigger means every new contact is automatically enrolled. You could also use EVENT to trigger on a specific signup event.


Step 2: Add the Welcome Email

const welcome = await pigeon.sequences.steps.create(seqId, {
  type: "SEND_EMAIL",
  config: {
    templateId: "tmpl_welcome",
    subject: "Welcome to {{appName}}, {{firstName}}!",
    fromName: "Alice from YourApp",
    fromEmail: "alice@yourapp.com",
    replyTo: "support@yourapp.com",
  },
});

This sends immediately on enrollment. The {{firstName}} and {{appName}} variables are pulled from the contact's fields.

Make the welcome email personal. Use a real person's name as the sender, keep it short, and include one clear action (e.g., "Complete your profile" or "Send your first email").


Step 3: Wait 1 Day

const wait1 = await pigeon.sequences.steps.create(seqId, {
  type: "WAIT",
  config: {
    type: "delay",
    duration: 86400, // 24 hours in seconds
  },
});

Step 4: Quick Start Guide

const quickStart = await pigeon.sequences.steps.create(seqId, {
  type: "SEND_EMAIL",
  config: {
    templateId: "tmpl_quick_start",
    subject: "Get started in 3 steps",
  },
});

Keep this actionable. Three numbered steps the user can do right now. Link directly to the relevant pages in your app.


Step 5: Wait 2 Days

const wait2 = await pigeon.sequences.steps.create(seqId, {
  type: "WAIT",
  config: {
    type: "delay",
    duration: 172800, // 2 days
  },
});

Step 6: Branch on Activation

This is where it gets interesting. Check if the user has completed your key activation action:

const branch = await pigeon.sequences.steps.create(seqId, {
  type: "BRANCH",
  config: {
    condition: {
      type: "has_tag",
      tag: "activated",
    },
  },
});

You'll need to tag contacts as activated when they complete the action in your app:

// In your app, when a user completes the key action:
await pigeon.contacts.update(contactId, {
  tags: { add: ["activated"] },
});

Step 7: Branch Paths

True path — user activated

const tipsEmail = await pigeon.sequences.steps.create(seqId, {
  type: "SEND_EMAIL",
  config: {
    templateId: "tmpl_power_tips",
    subject: "3 things you didn't know {{appName}} could do",
  },
});

False path — user hasn't activated

const nudgeEmail = await pigeon.sequences.steps.create(seqId, {
  type: "SEND_EMAIL",
  config: {
    templateId: "tmpl_nudge",
    subject: "Need help getting started?",
  },
});

Wire the branch to its paths:

await pigeon.sequences.steps.update(seqId, branch.id, {
  branchTrueStepId: tipsEmail.id,
  branchFalseStepId: nudgeEmail.id,
});

Step 8: Feature Highlight (Day 5)

const wait3 = await pigeon.sequences.steps.create(seqId, {
  type: "WAIT",
  config: { type: "delay", duration: 172800 },
});

const feature = await pigeon.sequences.steps.create(seqId, {
  type: "SEND_EMAIL",
  config: {
    templateId: "tmpl_feature_highlight",
    subject: "Did you know about {{featureName}}?",
  },
});

Step 9: Check-In + Tag as Onboarded (Day 7)

const wait4 = await pigeon.sequences.steps.create(seqId, {
  type: "WAIT",
  config: { type: "delay", duration: 172800 },
});

const checkIn = await pigeon.sequences.steps.create(seqId, {
  type: "SEND_EMAIL",
  config: {
    templateId: "tmpl_checkin",
    subject: "How's it going?",
  },
});

// Tag the contact as onboarded when they reach the end
const tagOnboarded = await pigeon.sequences.steps.create(seqId, {
  type: "UPDATE_CONTACT",
  config: {
    actions: [
      { type: "add_tag", tag: "onboarded" },
      { type: "set_field", field: "onboardingCompleted", value: true },
    ],
  },
});

Step 10: Wire Everything Together

await pigeon.sequences.steps.reorder(seqId, [
  { stepId: welcome.id, position: 0, nextStepId: wait1.id },
  { stepId: wait1.id, position: 1, nextStepId: quickStart.id },
  { stepId: quickStart.id, position: 2, nextStepId: wait2.id },
  { stepId: wait2.id, position: 3, nextStepId: branch.id },
  { stepId: branch.id, position: 4 },
  // Branch paths converge at wait3
  { stepId: tipsEmail.id, position: 5, nextStepId: wait3.id },
  { stepId: nudgeEmail.id, position: 6, nextStepId: wait3.id },
  { stepId: wait3.id, position: 7, nextStepId: feature.id },
  { stepId: feature.id, position: 8, nextStepId: wait4.id },
  { stepId: wait4.id, position: 9, nextStepId: checkIn.id },
  { stepId: checkIn.id, position: 10, nextStepId: tagOnboarded.id },
  { stepId: tagOnboarded.id, position: 11 },
]);

Step 11: Activate

await pigeon.sequences.activate(seqId);
console.log("Onboarding sequence is live!");

Every new contact is now automatically enrolled.


Monitoring

Check how the sequence is performing:

const stats = await pigeon.sequences.analytics(seqId);

console.log(`Total enrolled: ${stats.totalEnrolled}`);
console.log(`Active: ${stats.activeEnrolled}`);
console.log(`Completed: ${stats.completedCount}`);
console.log(`Exited early: ${stats.exitedCount}`);

// Check the branch step to see activation rates
const branchStats = await pigeon.sequences.steps.analytics(seqId, branch.id);
console.log(`Activated (true branch): ${branchStats.completedCount}`);

The Complete Script

Here's everything in one file you can run to set up the sequence:

import { SendPigeon } from "sendpigeon";

const pigeon = new SendPigeon(process.env.SENDPIGEON_API_KEY!);

async function createOnboardingSequence() {
  // Create
  const seq = await pigeon.sequences.create({
    name: "SaaS Onboarding",
    triggerType: "CONTACT_CREATED",
  });

  // Steps
  const s1 = await pigeon.sequences.steps.create(seq.id, {
    type: "SEND_EMAIL",
    config: { templateId: "tmpl_welcome", subject: "Welcome, {{firstName}}!" },
  });
  const w1 = await pigeon.sequences.steps.create(seq.id, {
    type: "WAIT", config: { type: "delay", duration: 86400 },
  });
  const s2 = await pigeon.sequences.steps.create(seq.id, {
    type: "SEND_EMAIL",
    config: { templateId: "tmpl_quick_start", subject: "Get started in 3 steps" },
  });
  const w2 = await pigeon.sequences.steps.create(seq.id, {
    type: "WAIT", config: { type: "delay", duration: 172800 },
  });
  const br = await pigeon.sequences.steps.create(seq.id, {
    type: "BRANCH",
    config: { condition: { type: "has_tag", tag: "activated" } },
  });
  const tips = await pigeon.sequences.steps.create(seq.id, {
    type: "SEND_EMAIL",
    config: { templateId: "tmpl_power_tips", subject: "3 things you didn't know" },
  });
  const nudge = await pigeon.sequences.steps.create(seq.id, {
    type: "SEND_EMAIL",
    config: { templateId: "tmpl_nudge", subject: "Need help getting started?" },
  });
  const w3 = await pigeon.sequences.steps.create(seq.id, {
    type: "WAIT", config: { type: "delay", duration: 172800 },
  });
  const s3 = await pigeon.sequences.steps.create(seq.id, {
    type: "SEND_EMAIL",
    config: { templateId: "tmpl_feature_highlight", subject: "Did you know?" },
  });
  const w4 = await pigeon.sequences.steps.create(seq.id, {
    type: "WAIT", config: { type: "delay", duration: 172800 },
  });
  const s4 = await pigeon.sequences.steps.create(seq.id, {
    type: "SEND_EMAIL",
    config: { templateId: "tmpl_checkin", subject: "How's it going?" },
  });
  const tag = await pigeon.sequences.steps.create(seq.id, {
    type: "UPDATE_CONTACT",
    config: { actions: [{ type: "add_tag", tag: "onboarded" }] },
  });

  // Wire branch
  await pigeon.sequences.steps.update(seq.id, br.id, {
    branchTrueStepId: tips.id,
    branchFalseStepId: nudge.id,
  });

  // Order
  await pigeon.sequences.steps.reorder(seq.id, [
    { stepId: s1.id, position: 0, nextStepId: w1.id },
    { stepId: w1.id, position: 1, nextStepId: s2.id },
    { stepId: s2.id, position: 2, nextStepId: w2.id },
    { stepId: w2.id, position: 3, nextStepId: br.id },
    { stepId: br.id, position: 4 },
    { stepId: tips.id, position: 5, nextStepId: w3.id },
    { stepId: nudge.id, position: 6, nextStepId: w3.id },
    { stepId: w3.id, position: 7, nextStepId: s3.id },
    { stepId: s3.id, position: 8, nextStepId: w4.id },
    { stepId: w4.id, position: 9, nextStepId: s4.id },
    { stepId: s4.id, position: 10, nextStepId: tag.id },
    { stepId: tag.id, position: 11 },
  ]);

  // Activate
  await pigeon.sequences.activate(seq.id);
  console.log(`Onboarding sequence live: ${seq.id}`);
}

createOnboardingSequence();

Tips

  • Test in dev mode first — Use SENDPIGEON_DEV=true and the local dev server to preview all emails before going live
  • Keep emails short — Onboarding emails should be 2-3 paragraphs max with one clear CTA
  • Use plain text style — For B2B SaaS, plain-text-looking emails from a real person outperform branded HTML
  • Track activation, not opens — The sequence works if users activate, not if they open emails
  • Iterate — Check the branch analytics after 2 weeks. If 80% go down the nudge path, your activation UX needs work, not more emails

Next Steps