Notifications & Email

Build durable notification and email workflows with drip campaigns, verification flows, fan-out delivery, and scheduled sends.

Workflows are a natural fit for notification systems. They survive failures, support delays with sleep(), and handle complex routing logic durably. A failed email send retries automatically. A drip campaign that spans days or weeks runs without consuming compute while waiting.

This guide covers the most common notification and email patterns.

Email Onboarding Sequence

A drip campaign sends a series of emails spaced out over time. Each email send is a step with automatic retry, and sleep() pauses the workflow without consuming resources between sends.

workflows/onboarding.ts
import { sleep } from "workflow";

declare function sendWelcomeEmail(userId: string): Promise<void>; // @setup
declare function sendTipsEmail(userId: string): Promise<void>; // @setup
declare function sendActivationPrompt(userId: string): Promise<void>; // @setup

export async function onboardingEmailWorkflow(userId: string) {
  "use workflow";

  await sendWelcomeEmail(userId);

  await sleep("1 day"); 
  await sendTipsEmail(userId);

  await sleep("3 days"); 
  await sendActivationPrompt(userId);
}
workflows/onboarding-steps.ts
import { FatalError } from "workflow";

declare const db: { getUser(id: string): Promise<{ name: string; email: string }> }; // @setup
declare const emailClient: { send(opts: { to: string; template: string; data: Record<string, string> }): Promise<void> }; // @setup

export async function sendWelcomeEmail(userId: string) {
  "use step";

  const user = await db.getUser(userId);
  if (!user.email) throw new FatalError("User has no email address"); 

  await emailClient.send({
    to: user.email,
    template: "welcome",
    data: { name: user.name },
  });
}

export async function sendTipsEmail(userId: string) {
  "use step";

  const user = await db.getUser(userId);
  await emailClient.send({
    to: user.email,
    template: "tips",
    data: { name: user.name },
  });
}

export async function sendActivationPrompt(userId: string) {
  "use step";

  const user = await db.getUser(userId);
  await emailClient.send({
    to: user.email,
    template: "activation",
    data: { name: user.name },
  });
}

The workflow sleeps for real calendar time between sends. If the process restarts during a sleep, the workflow resumes at the correct point. Use FatalError to skip retries for permanent failures like a missing email address.

Email sends are side effects. Make sure your email provider supports idempotency keys or deduplication to avoid sending duplicate emails on retry. See Idempotency for more details.

Email Verification Flow

A common pattern is sending a verification email, then waiting for the user to click a link. Use createWebhook() to generate a unique URL, then Promise.race with sleep() to set an expiration deadline.

workflows/verify-email.ts
import { sleep, createWebhook } from "workflow";

declare function sendVerificationEmail(email: string, verifyUrl: string): Promise<void>; // @setup
declare function markEmailVerified(userId: string): Promise<void>; // @setup

export async function emailVerificationWorkflow(userId: string, email: string) {
  "use workflow";

  const webhook = createWebhook();

  // Send the verification link
  await sendVerificationEmail(email, webhook.url); 

  // Wait for the click or expire after 24 hours
  const result = await Promise.race([ 
    webhook.then(() => "verified" as const), 
    sleep("1 day").then(() => "expired" as const), 
  ]); 

  if (result === "verified") {
    await markEmailVerified(userId);
    return { status: "verified" };
  }

  return { status: "expired" };
}

The webhook URL is automatically routed by the framework. When the user clicks the link, the webhook resolves and the workflow continues. If 24 hours pass first, the sleep wins the race and the workflow returns an expired status.

Contact Form Processing

When a form submission triggers multiple actions - notifying the team, creating a CRM record, confirming to the submitter - each action is a separate step. If one fails, the others still complete and the failed step retries independently.

workflows/contact-form.ts
declare function notifyTeam(submission: ContactSubmission): Promise<void>; // @setup
declare function createCrmRecord(submission: ContactSubmission): Promise<string>; // @setup
declare function sendConfirmationEmail(email: string, crmId: string): Promise<void>; // @setup

interface ContactSubmission {
  name: string;
  email: string;
  message: string;
}

export async function contactFormWorkflow(submission: ContactSubmission) {
  "use workflow";

  // Notify team and create CRM record in parallel
  const [, crmId] = await Promise.all([ 
    notifyTeam(submission), 
    createCrmRecord(submission), 
  ]); 

  // Confirm to the submitter with the CRM reference
  await sendConfirmationEmail(submission.email, crmId);

  return { crmId, status: "processed" };
}
workflows/contact-form-steps.ts
declare const emailClient: { send(opts: Record<string, unknown>): Promise<void> }; // @setup
declare const crm: { create(data: Record<string, string>): Promise<{ id: string }> }; // @setup

interface ContactSubmission {
  name: string;
  email: string;
  message: string;
}

export async function notifyTeam(submission: ContactSubmission) {
  "use step";

  await emailClient.send({
    to: "team@example.com",
    subject: `New contact: ${submission.name}`,
    body: submission.message,
  });
}

export async function createCrmRecord(submission: ContactSubmission) {
  "use step";

  const record = await crm.create({
    name: submission.name,
    email: submission.email,
    message: submission.message,
  });

  return record.id;
}

export async function sendConfirmationEmail(email: string, crmId: string) {
  "use step";

  await emailClient.send({
    to: email,
    template: "contact-confirmation",
    data: { referenceId: crmId },
  });
}

Notification Fan-Out

When you need to send the same notification across multiple channels - email, Slack, push notifications - use Promise.all with a separate step for each channel. Each channel retries independently if it fails.

workflows/notify.ts
declare function sendEmailNotification(userId: string, message: string): Promise<void>; // @setup
declare function sendSlackNotification(userId: string, message: string): Promise<void>; // @setup
declare function sendPushNotification(userId: string, message: string): Promise<void>; // @setup

export async function notifyAllChannelsWorkflow(userId: string, message: string) {
  "use workflow";

  await Promise.all([ 
    sendEmailNotification(userId, message), 
    sendSlackNotification(userId, message), 
    sendPushNotification(userId, message), 
  ]); 
}
workflows/notify-steps.ts
declare const db: { getUser(id: string): Promise<{ name: string; email: string; slackId?: string; pushToken?: string }> }; // @setup
declare const emailClient: { send(opts: Record<string, unknown>): Promise<void> }; // @setup
declare const slack: { postMessage(opts: { channel: string; text: string }): Promise<void> }; // @setup
declare const pushService: { send(opts: { token: string; title: string; body: string }): Promise<void> }; // @setup

export async function sendEmailNotification(userId: string, message: string) {
  "use step";

  const user = await db.getUser(userId);
  await emailClient.send({
    to: user.email,
    subject: "New Notification",
    body: message,
  });
}

export async function sendSlackNotification(userId: string, message: string) {
  "use step";

  const user = await db.getUser(userId);
  if (!user.slackId) return; // Skip if no Slack configured

  await slack.postMessage({
    channel: user.slackId,
    text: message,
  });
}

export async function sendPushNotification(userId: string, message: string) {
  "use step";

  const user = await db.getUser(userId);
  if (!user.pushToken) return; // Skip if no push token

  await pushService.send({
    token: user.pushToken,
    title: "Notification",
    body: message,
  });
}

To fan out to many recipients, map over the list and run each send as a step:

workflows/broadcast.ts
declare function sendNotification(userId: string, message: string): Promise<void>; // @setup

export async function broadcastWorkflow(userIds: string[], message: string) {
  "use workflow";

  await Promise.all( 
    userIds.map((userId) => sendNotification(userId, message)) 
  ); 
}

Scheduled Notifications

Use sleep() with a Date to send notifications at a specific future time. The workflow suspends until that moment arrives.

workflows/scheduled-reminder.ts
import { sleep } from "workflow";

declare function sendReminderEmail(userId: string, eventName: string): Promise<void>; // @setup

export async function scheduledReminderWorkflow(
  userId: string,
  eventName: string,
  eventDate: Date
) {
  "use workflow";

  // Calculate reminder time: 1 day before the event
  const reminderDate = new Date(eventDate.getTime() - 24 * 60 * 60 * 1000);

  await sleep(reminderDate); 
  await sendReminderEmail(userId, eventName);

  return { status: "reminder_sent", sentAt: reminderDate };
}

When you pass a Date to sleep(), the workflow suspends until that exact moment. If the date is in the past, sleep() resolves immediately. This is useful for scheduling reminders, deadline notifications, or any time-based trigger.