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.
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.
The sequence:
- Welcome email (immediate)
- Quick start guide (Day 1)
- Branch: activated? → tips or nudge
- Feature highlight (Day 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=trueand 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
- Drip email API guide — full API reference for sequences
- Sequences documentation — all endpoints and step types
- Send email in Next.js — transactional email alongside sequences
- Inbound email parsing — receive replies to sequence emails