Skip to content

tutorials

Build a SaaS onboarding email sequence triggered by product events

August 24, 2026

#onboarding-email#lifecycle-email#drip-sequence#cloudflare-workers#tutorial
Build a SaaS onboarding email sequence triggered by product events
Visual: Marcus Reed on okslop

A good onboarding sequence reacts to what users do, not just to the clock. Day 3 should nudge people who haven't created a project yet, and stay silent for the ones who already did. That conditional behavior is exactly what onboarding SaaS tools sell you, holding a second copy of your user list to do it. Here is how the machine actually works, built from scratch, then the shortcut that keeps it on data you own.

"Send a welcome email" is trivial. "Send a five-step onboarding sequence that branches on behavior and shuts up when the user converts" is a small state machine running once per user, and that is where the real work lives. The naive version, a cron that fires "day 3" email to everyone who signed up three days ago, is worse than nothing: it emails people who already activated, it double-sends when invocations overlap, and it keeps drip-ing users who long since converted.

Let's build the real thing on Cloudflare: D1 for state, Cron Triggers for timing, Queues for fan-out. We'll be honest about each sharp edge (idempotency, event gating, exits), then look at doing it as one owned deploy instead of renting a lifecycle tool.

What is an event-triggered onboarding sequence?

An event-triggered onboarding sequence is a per-user state machine where product events advance, gate, or exit the drip, instead of email firing purely on elapsed time. A time-based drip says "send step 3 on day 3." An event-triggered sequence says "send step 3 on day 3, but only if the user hasn't created a project yet, and exit the whole sequence the moment they upgrade."

Most production sequences are a hybrid:

  • A time backbone: step 1 at enrollment, step 2 at +1 day, step 3 at +3 days, step 4 at +7 days.
  • Event gates: a step can require a prior event (only send the "invite your team" email after the user created a project), or be skipped by an event (don't send "create your first project" if they already did).
  • An exit event: the goal action the whole sequence is nudging toward (activation, upgrade). Hitting it ends the drip.

So you need four things: somewhere events land, somewhere per-user progress lives, a scheduler that decides what's due, and exit logic. Build those and the sequences themselves become configuration.

How do I structure the sequence as data?

Define the sequence as a plain data structure (an ordered list of steps with delays, gates, and templates) so adding or reordering a step is a config change, not new code. Hardcoding "if day === 3 send X" across your codebase is how onboarding logic rots.

// sequences.ts
type Step = {
  key: string;            // unique within the sequence
  delayMs: number;        // time after enrollment before this step is due
  template: string;       // template name to render
  requiresEvent?: string; // only send if this event has fired
  skipIfEvent?: string;   // skip (mark done) if this event has fired
  stream: "marketing";    // onboarding drips are lifecycle mail
};

type Sequence = { key: string; exitEvent: string; steps: Step[] };

const DAY = 864e5;

export const ONBOARDING: Sequence = {
  key: "onboarding",
  exitEvent: "activated", // user did the thing; stop nudging
  steps: [
    { key: "welcome",  delayMs: 0,       template: "ob-welcome", stream: "marketing" },
    { key: "setup",    delayMs: 1 * DAY, template: "ob-setup",
      skipIfEvent: "project_created", stream: "marketing" },
    { key: "invite",   delayMs: 3 * DAY, template: "ob-invite",
      requiresEvent: "project_created", stream: "marketing" },
    { key: "nudge",    delayMs: 7 * DAY, template: "ob-nudge",
      skipIfEvent: "activated", stream: "marketing" },
  ],
};

Read that as a flowchart: everyone gets welcome immediately. On day 1, setup goes out unless they already created a project. On day 3, invite goes out only to people who did create a project. On day 7, a final nudge, skipped if they activated. The exit event activated short-circuits everything.

How do I capture product events?

Add an events table and write a row from your app whenever something meaningful happens, because those events both enroll users into sequences and gate or exit their steps. The events table is the spine of the whole system.

CREATE TABLE events (
  id         TEXT PRIMARY KEY,
  user_id    TEXT NOT NULL,
  name       TEXT NOT NULL,     -- 'signup' | 'project_created' | 'activated' | ...
  created_at INTEGER NOT NULL
);
CREATE INDEX events_user_name_idx ON events (user_id, name);
// emit.ts: call this from your product code
export async function emit(env: Env, userId: string, name: string) {
  await env.DB.prepare(
    "INSERT INTO events (id, user_id, name, created_at) VALUES (?, ?, ?, ?)",
  )
    .bind(crypto.randomUUID(), userId, name, Date.now())
    .run();

  // Enrollment: the 'signup' event puts the user into onboarding.
  if (name === "signup") await enroll(env, userId, "onboarding");
}

Now your app sprinkles one-liners where things happen: emit(env, user.id, "signup") on registration, emit(env, user.id, "project_created") when they make a project, emit(env, user.id, "activated") when they hit your activation milestone. The sequence engine never touches your product code beyond these calls. If you want these events to leave your system too (analytics, a CRM), that is the same emit pattern wired to a webhook, covered in emitting webhook events from your email stack.

How do I track per-user sequence state without double-sending?

Store one row per enrolled user in a state table, plus a step-log table with a unique constraint on (user, sequence, step) so each step can be claimed exactly once. The unique constraint is what makes the whole thing safe under overlapping cron ticks and racing events.

CREATE TABLE sequence_state (
  user_id     TEXT NOT NULL,
  sequence    TEXT NOT NULL,
  enrolled_at INTEGER NOT NULL,
  status      TEXT NOT NULL DEFAULT 'active',  -- 'active' | 'exited'
  PRIMARY KEY (user_id, sequence)
);

-- one row per step actually sent (or skipped); the PK prevents duplicates
CREATE TABLE sequence_steps (
  user_id   TEXT NOT NULL,
  sequence  TEXT NOT NULL,
  step      TEXT NOT NULL,
  sent_at   INTEGER,            -- null while claimed-but-not-yet-sent
  outcome   TEXT NOT NULL,      -- 'sent' | 'skipped'
  PRIMARY KEY (user_id, sequence, step)
);
// enroll.ts
export async function enroll(env: Env, userId: string, sequence: string) {
  await env.DB.prepare(
    `INSERT OR IGNORE INTO sequence_state (user_id, sequence, enrolled_at, status)
     VALUES (?, ?, ?, 'active')`,
  )
    .bind(userId, sequence, Date.now())
    .run();
}

The INSERT OR IGNORE means a user who somehow fires signup twice still gets exactly one enrollment. The same trick claims a step before sending: insert the step row first, and only the invocation whose insert succeeds gets to send. This is the difference between "select due users then send" (double-sends under load) and a correct claim-then-send.

How do I resolve and send due steps on a schedule?

Run a Cron Trigger that, for each active enrollment, computes the next unclaimed step, checks its time delay and event gates, atomically claims it, then renders and enqueues the send. The scheduler is the only place timing lives, so the sequence definition stays declarative.

// scheduled.ts (wrangler.jsonc: { "triggers": { "crons": ["*/15 * * * *"] } })
import { ONBOARDING } from "./sequences";
import { render } from "./templates";
import { isSuppressed } from "./suppression";

export default {
  async scheduled(_e: ScheduledController, env: Env) {
    const seq = ONBOARDING;
    const now = Date.now();

    const active = await env.DB.prepare(
      `SELECT user_id, enrolled_at FROM sequence_state
       WHERE sequence = ? AND status = 'active'`,
    )
      .bind(seq.key)
      .all<{ user_id: string; enrolled_at: number }>();

    for (const row of active.results) {
      // exit check first: did the goal event fire?
      if (await hasEvent(env, row.user_id, seq.exitEvent)) {
        await env.DB.prepare(
          `UPDATE sequence_state SET status = 'exited'
           WHERE user_id = ? AND sequence = ?`,
        ).bind(row.user_id, seq.key).run();
        continue;
      }

      // find the first step that is due and not yet claimed
      const done = await claimedSteps(env, row.user_id, seq.key);
      const step = seq.steps.find(
        (s) => !done.has(s.key) && now - row.enrolled_at >= s.delayMs,
      );
      if (!step) continue;

      // gates
      if (step.requiresEvent && !(await hasEvent(env, row.user_id, step.requiresEvent))) {
        continue; // not eligible yet; try again next tick
      }
      const skip =
        step.skipIfEvent && (await hasEvent(env, row.user_id, step.skipIfEvent));

      // claim the step idempotently; only one invocation wins
      const claim = await env.DB.prepare(
        `INSERT OR IGNORE INTO sequence_steps (user_id, sequence, step, outcome)
         VALUES (?, ?, ?, ?)`,
      )
        .bind(row.user_id, seq.key, step.key, skip ? "skipped" : "sent")
        .run();
      if (claim.meta.changes === 0) continue; // someone else claimed it
      if (skip) continue;                      // recorded as skipped, no send

      // enqueue the actual send (fan-out keeps the cron cheap)
      await env.SEND_QUEUE.send({
        userId: row.user_id,
        sequence: seq.key,
        step: step.key,
        template: step.template,
      });
    }
  },
};

async function hasEvent(env: Env, userId: string, name: string): Promise<boolean> {
  const r = await env.DB.prepare(
    "SELECT 1 FROM events WHERE user_id = ? AND name = ? LIMIT 1",
  ).bind(userId, name).first();
  return r != null;
}

async function claimedSteps(env: Env, userId: string, sequence: string) {
  const r = await env.DB.prepare(
    "SELECT step FROM sequence_steps WHERE user_id = ? AND sequence = ?",
  ).bind(userId, sequence).all<{ step: string }>();
  return new Set(r.results.map((x) => x.step));
}

Notice what the claim.meta.changes === 0 guard buys you: if two overlapping cron runs both pick the same step, only the first INSERT OR IGNORE reports a change, so only one send is enqueued. That is the entire double-send defense, and it costs one row. Why fan out to a Queue instead of sending inline? Because awaiting hundreds of HTTPS sends in one cron invocation will exhaust your CPU time; the batching pattern is in sending email reliably at volume with Queues and sending to a large list in batches. The reliability patterns that keep that fan-out from dropping or duplicating mail (retries, dead-letter queues, consumer-side rate limits) are collected in Cloudflare Queues patterns for reliable sending, and the cron half of this scheduler fits the broader set of ways Cloudflare Cron Triggers can run your email.

The queue consumer does the suppression check and the actual delivery:

// consumer.ts
export async function queue(batch: MessageBatch, env: Env) {
  for (const msg of batch.messages) {
    const { userId, template } = msg.body as { userId: string; template: string };
    const user = await getUser(env, userId);

    // marketing/lifecycle mail respects unsubscribes and bounces
    if (await isSuppressed(env.DB, user.email)) { msg.ack(); continue; }

    const { subject, html, text } = render(template, {
      firstName: user.firstName,
      unsubscribeUrl: `https://yourdomain.com/u/${userId}`,
    });

    const res = await fetch(env.DELIVERY_URL, {
      method: "POST",
      headers: { authorization: `Bearer ${env.DELIVERY_KEY}`, "content-type": "application/json" },
      body: JSON.stringify({
        to: user.email,
        from: "hello@news.yourdomain.com",
        subject, html, text,
      }),
    });

    if (!res.ok) { msg.retry(); continue; } // Queues redelivers on retry
    await env.DB.prepare(
      "UPDATE sequence_steps SET sent_at = ? WHERE user_id = ? AND step = ?",
    ).bind(Date.now(), userId, msg.body.step).run();
    msg.ack();
  }
}

Two correctness notes. The send goes over HTTPS to a delivery layer, not over SMTP, because the Workers runtime cannot open port 25; that constraint and the reasoning behind it are in why edge email needs an HTTPS sender. And lifecycle mail must carry a working one-click unsubscribe header, which is a legal requirement for bulk sending now, covered in implementing one-click unsubscribe.

How do I stop the sequence once a user converts?

Record an exit event when the user performs the goal action and check for it before every send, then move the sequence row to a terminal state. The exit check at the top of the scheduler loop above is doing exactly this: any user with the activated event gets their enrollment flipped to exited and is skipped forever after.

The failure mode this prevents is the one everyone has experienced as a user: you finish setting up a product on day 2, and on day 7 it emails you "still haven't gotten started?" That single mistimed email signals the product isn't paying attention, and it's caused entirely by a drip with no exit. Two refinements worth adding:

  • Per-step skips vs. full exit. skipIfEvent skips one step (they already created a project, so don't send the "create a project" email). The exitEvent ends the whole sequence (they activated, so stop entirely). You usually want both.
  • Re-enrollment guard. Because enrollment is INSERT OR IGNORE on a primary key, a user who churns and comes back won't be re-dripped unless you explicitly reset their state. That's usually what you want; make it a deliberate decision.

At this point you have a complete, correct engine: events drive enrollment and gating, a cron resolves due steps idempotently, a queue delivers them with suppression, and conversions end the drip.

The SaaS version (and what it actually costs)

If you don't build the above, the default is to rent it: a lifecycle tool like Customer.io, Loops, or a marketing-automation suite. Those products are genuinely good at the visual flow builder and the analytics. The cost is structural, not just dollars:

  • They need your user list and your events, so you maintain a second copy of both, perpetually syncing. Every schema change is now a two-system change.
  • They price per contact, so your bill grows as your list grows even if your send volume is flat. The arithmetic on that gets ugly fast, laid out in escaping the per-contact pricing trap.
  • Your onboarding logic, the thing most tied to your product's behavior, lives in someone else's UI where you can't unit-test it, version it in git, or query it.

For a visual marketer this trade is often worth it. For a developer whose events already live in their own database, you're paying a per-contact tax to move logic out of the system that has all the context. The honest comparison of owning versus renting the send side is in the Resend alternative breakdown.

The shortcut: event-triggered sequences on your own Cloudflare account

Everything above is the correct architecture, and understanding the state-machine shape is worth the read. But notice that the events table, the sequence state, the scheduler, and the delivery layer are one system sitting on top of data you already own. That's the gap Tinbox fills: it deploys the whole thing into your own Cloudflare account in one command.

npx tinbox init yourdomain.com

That scaffolds the events intake, the sequence engine (Cron Triggers plus Queues, idempotent claims and all), suppression, and the delivery layer with DKIM signing, on your D1, R2, and Queues. Your product code just emits events:

import { tinbox } from "tinbox/cf";

// fire a product event; enrolled sequences react to it
await tinbox(env).track(user.id, "project_created");

// enrollment and exits are sequence config, not glue code
await tinbox(env).enroll(user.id, "onboarding");

Because the sequence logic, the user data, and the send logs are all in one database on your account, you can write a SQL query to answer "how many users dropped out after step 2 last month" without exporting anything to a vendor. One subprocessor (Cloudflare), one bill, one copy of your contacts, and no per-contact pricing on the drip.

Two honest caveats so you can plan around them. Each deploy serves one brand and domain today; you run many tenants by deploying once per domain on the same Cloudflare account, and multi-domain-per-instance is designed but not yet built. And some runtime email routing is gated behind your own committed config rather than being fully dynamic. For a single-product SaaS dripping from one domain, that's the shape you want anyway. If you're starting further back, adding transactional and lifecycle email to your SaaS covers the send path this builds on, and reusable templates with variables covers the template layer the sequence renders through.

Deploy this yourself: npx tinbox init yourdomain.com. The event tracking, sequence engine, suppression, and inbox setup are all in the Tinbox docs.

Frequently asked questions

How do I build a product-event-triggered onboarding email sequence?

Model each user's progress as rows in a sequence-state table. When your app emits a product event (signup, activated, invited a teammate), write it to an events table. A scheduler resolves the next eligible step per enrolled user: time-based steps fire when enough time has passed since enrollment, and event-gated steps fire (or get skipped) based on whether the gating event arrived. Mark each step sent so it never repeats, and define an exit event (the user did the thing the drip was nudging) that ends the sequence early.

What is the difference between a time-based drip and an event-triggered sequence?

A time-based drip sends email N days after enrollment regardless of behavior. An event-triggered sequence reacts to what the user does: it can advance, branch, or exit based on product events like 'created first project' or 'invited a teammate.' Most good onboarding sequences are a mix: a time backbone (day 1, day 3, day 7) with event gates that skip a step if the user already completed its goal, so you never nag someone to do a thing they have already done.

How do I stop sending onboarding emails once a user converts?

Define an exit event for the sequence and check for it before each send. When the user performs the goal action (activates, upgrades, completes setup), record an exit event and set the sequence row to a terminal state. The scheduler skips any user whose sequence is exited. Without this, your most engaged users get a 'have you tried this yet?' email about something they did last week, which reads as a broken product.

How do I make sure an onboarding email is not sent twice?

Make each step idempotent. Store one row per (user, sequence, step) with a unique constraint and a sent_at timestamp, and use INSERT OR IGNORE when claiming a step before you send. If two cron ticks or two events race, only one claim succeeds, so only one email goes out. Never rely on 'select due users, then send' without a claim step, because overlapping invocations will double-send.

Can I build onboarding sequences on Cloudflare Workers without a SaaS like Customer.io?

Yes. Use D1 for the events and sequence-state tables, a Cron Trigger to resolve due steps on a schedule, and Queues to fan out the actual sends so one invocation does not blow the CPU budget. The delivery itself goes over HTTPS because Workers cannot open port 25 for SMTP. This keeps your user list, your sequence logic, and your send logs in one database you own instead of a second copy living in a vendor.