Skip to content
SendOps

How to Build a Drip Workflow on Amazon SES

Build a drip email workflow on Amazon SES step by step: SendQL segments, activity triggers, React Email templates, SendFlow code and a safe shadow-mode launch.

Kash Sajadi 16 min read

Amazon Simple Email Service (SES) is one of the most reliable and cost-effective ways to send email at scale. At $0.10 per thousand emails, SES solves the raw infrastructure problem cleanly. But SES is intentionally bare delivery infrastructure, not a campaign engine.

SES has contact-list and subscription-management features, but a multi-step lifecycle journey also needs timers, behavioral branches, and execution state. When an engineering team needs to run an onboarding email sequence or an automated lifecycle drip, they quickly hit the boundary between raw message transport and application-layer email automation.

In this guide, we will walk through building an automated trial-onboarding drip workflow on Amazon SES from scratch. You will see how to ingest custom application telemetry, define dynamic audiences, compile production-safe React Email templates, orchestrate multi-step branches, and rehearse the workflow before sending a single live message.

Disclosure: This tutorial is written by the SendOps team. We build the SendOps workflow engine to sit directly on top of your existing Amazon SES account, and the practical implementation examples below use our tooling and syntax.


What You Would Have to Build Yourself on Raw SES

Before jumping into implementation, it helps to understand what an email workflow engine actually does under the hood. Consider a trial-onboarding sequence:

  1. Send a welcome email immediately upon signup.
  2. Wait 2 days. If the user has not opened the welcome email, send a reminder.
  3. Send activation tips on Day 2 if the welcome was opened, or wait another 2 days after the reminder.
  4. Wait up to 7 days for the user to log in twice. If they fail to activate, send a targeted check-in message.
  5. Exit at the next step check if the user leaves the trial or their plan is unknown.

If you build this entirely on your own AWS infrastructure, you must assemble and maintain a distributed system spanning several services:

  • DynamoDB or PostgreSQL: To track contact records, subscription state, topic preferences, suppression lists, and run state for every in-flight execution.
  • Amazon EventBridge Scheduler or AWS Step Functions: To handle durable timers and delays without leaving compute instances running.
  • Amazon SNS + AWS Lambda: To capture SES delivery, open, click, bounce, and complaint webhooks, parse the JSON payload, and update user records.
  • AWS Lambda: To execute custom branching logic at each step (such as verifying whether an open event was recorded or if an exit condition was satisfied).
  • Template Storage: Storing raw HTML or MJML in S3, compiling Handlebars or Mustache variables, and calling the SES SendTemplatedEmail API.

The DIY Trade-Off

Operational AreaDIY Stack (EventBridge + Step Functions + Lambda)Workflow Layer on SES (SendOps)
Who Can EditDepends on the editing interface and deployment process you buildDevelopers via Git (.flow files) or Marketers via visual canvas
Execution VisibilityCloudWatch logs, Step Functions console, custom metricsVisual run timelines, per-node conversion funnels, drop-off tracking
Consent HandlingCustom database schema, custom unsubscribe links, manual suppression checksBuilt-in suppression lists, topic lanes (transactional vs marketing), RFC-8058 compliant headers
Testing & SafetyCustom staging queues and test accountsBuilt-in shadow mode against production traffic without sending
Cost ModelAWS service charges (Step Functions transitions, Lambda, DynamoDB) + dev hoursPredictable flat platform subscription + separate AWS SES charges

Building an in-house automation stack makes sense if you have an engineering team with spare maintenance capacity and an unchanging, hardcoded email sequence that product managers never need to inspect or adjust. However, as soon as growth teams want to change timing, introduce branches, or inspect why a specific user did not receive a message, maintaining internal glue code becomes an expensive distraction.

Evaluating your infrastructure options? See our comprehensive breakdown of drip campaign software architecture.


The Example We Will Build

Throughout this tutorial, we will build a complete trial onboarding journey with the following logic:

  • Day 0: Deliver a transactional welcome template immediately upon entering the trial.
  • Day 2: If the user has not opened the welcome email, send a welcome-reminder under the onboarding topic.
  • Day 2 or Day 4: Deliver activation-tips on Day 2 for welcome openers, or on Day 4 after the reminder branch’s extra 2-day wait.
  • Then wait up to 7 days: Check for at least two logins in the rolling 7-day window (count(activity.login within 7d) >= 2). The timeout is around Day 9 or Day 11, depending on the branch and any send deferrals.
  • Timeout Rescue: If 7 days pass without 2 logins, send a need-a-hand outreach email.
  • Global Exit: Before each step, stop if the contact is no longer on trial or their plan is unknown. Name this exit left-trial; a cancellation is not a conversion.

Let’s implement each component step by step.


Step 1: Send Product Activity into SendOps

To make smart decisions, an email workflow engine needs to know what your users are doing in your application. In SendOps, there is an important distinction between events and activities:

  • Events are delivery-layer signals generated automatically by Amazon SES. They use a fixed vocabulary: send, delivery, open, click, bounce, complaint, reject, and delivery_delay.
  • Activities are custom domain signals sent from your application code, representing user actions (such as logging in, creating an invoice, or purchasing a subscription). In segment and workflow syntax, activities are always prefixed with activity..

Ingesting an Activity

When a user logs in, send an activity payload to the SendOps REST API:

curl -X POST https://api.sendops.dev/v1/activities \
  -H "Authorization: Bearer $SENDOPS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "login",
    "email": "alex@example.com",
    "properties": {
      "ip": "203.0.113.19",
      "user_agent": "Mozilla/5.0"
    },
    "idempotency_key": "login_evt_839210"
  }'

The API uses the current time when occurred_at is omitted. Supply the real event time when replaying historical activity, and use a distinct idempotency key for each actual login.

Handling Lifecycle Milestones Safely

For major lifecycle events like creating an account or starting a trial, standard 24-hour idempotency windows are not enough. If your webhooks retry or user events fire across multiple browser tabs, you want to guarantee that a user is flagged for a milestone exactly once across their lifetime. You can pass "dedup_mode": "once":

curl -X POST https://api.sendops.dev/v1/activities \
  -H "Authorization: Bearer $SENDOPS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "trial_started",
    "email": "alex@example.com",
    "properties": {
      "tier": "growth",
      "referral_code": "launch2026"
    },
    "dedup_mode": "once"
  }'

Using "dedup_mode": "once" creates a durable, permanent milestone record for that contact. Subsequent requests with that activity name and contact identifier will be deduplicated automatically.

Key Implementation Details:

  1. Activity names must be lowercase snake_case and are case-sensitive.
  2. Contacts that do not yet exist in your audience database are automatically provisioned as lightweight stub records upon activity ingestion.
  3. The endpoint returns 202 Accepted and processes batches up to 1,000 items at a time.
  4. To filter on dynamic nested fields inside properties, promote those keys using POST /v1/activity-properties. Promotion applies retroactively to previously ingested data. Learn more in the SendOps activities documentation.

Step 2: Define the Audience with a SendQL Segment

With activity streaming into your account, you can define audience segments using SendQL, a declarative query language designed specifically for audience filtering.

Create a Segment with the key trial-started using this rule. Define and populate the contact’s plan attribute first, and update it when the plan changes; a trial_started activity or its properties.tier field does not set attr.plan for you. Create the onboarding topic and the four referenced templates before validating the workflow.

Here is our baseline segment for contacts currently on a trial:

attr.plan = "trial" and not suppressed

SendQL supports rich relative time windows, event aggregations, and activity absences. For example, here is a segment targeting users 3 to 14 days into their trial who have clicked an email recently but have not completed an order:

attr.plan = "trial" and has attr.signup_date and now - attr.signup_date between 3d and 14d and count(click within 7d) >= 1 and not exists(activity.order)

Because SendQL statements can live directly in your source code, you can document the business logic inline using comments:

// Engaged trial users who have not completed onboarding setup
attr.plan = "trial"
  and count(activity.login within 7d) >= 2
  and not exists(activity.project_created)
  and not suppressed

Critical Gotcha: Absence Semantics

When writing audience queries, pay careful attention to null and missing field handling:

  1. In SendQL, attr.plan != "pro" will not match contacts who do not have a plan attribute defined. To include users where the attribute has never been populated, explicitly use an unknown guard: (attr.plan != "pro" or attr.plan is unknown).
  2. Similarly, evaluating last(open) < now - 14d excludes contacts who have never opened an email, because last(open) evaluates to null. If you want to include cold contacts who signed up but never opened anything, guard with open counts: (last(open) < now - 14d or count(open) = 0).

For full syntax rules, check the SendQL segment syntax guide.


Step 3: Write the Emails as React Email Templates

One of the biggest pitfalls when managing email templates on Amazon SES is strict rendering. When you use SES template APIs directly, rendering happens in two separate phases:

  1. Build time: Your React components render into static HTML and CSS layouts.
  2. Send time: Amazon SES substitutes recipient data via Handlebars tags (e.g., {{firstName}}).

If SES encounters an undefined Handlebars variable during send time, the API can accept the request and return a message ID before rendering fails asynchronously. Capture Rendering Failure events, provide every required variable, and test the rendered template. Optional-value fallbacks help, but do not replace validation.

Safe Handlebars Helpers

Create a helper module in your template directory (lib/vars.ts):

// lib/vars.ts
export function cp(name: string): string {
  return `{{${name}}}`;
}

export function cpOr(name: string, fallback: string): string {
  return `{{#if ${name}}}{{${name}}}{{else}}${fallback}{{/if}}`;
}

The Welcome Email Template

Now build your email using standard React Email components, pairing cpOr() for nullable strings with cp() for required fields:

// emails/welcome.tsx
import * as React from "react";
import { Html, Body, Container, Heading, Text, Button } from "@react-email/components";
import { cp, cpOr } from "../lib/vars";

export default function WelcomeEmail() {
  return (
    <Html>
      <Body style={{ backgroundColor: "#f9fafb", fontFamily: "sans-serif" }}>
        <Container style={{ padding: "32px", backgroundColor: "#ffffff" }}>
          <Heading style={{ fontSize: "24px", color: "#111827" }}>
            Welcome, {cpOr("firstName", "there")}!
          </Heading>
          <Text style={{ fontSize: "16px", color: "#4b5563" }}>
            Thanks for starting your trial. You have 14 days of full access to explore the platform.
          </Text>
          <Button
            href={cp("appUrl")}
            style={{
              backgroundColor: "#2563eb",
              color: "#ffffff",
              padding: "12px 20px",
              borderRadius: "6px",
              textDecoration: "none"
            }}
          >
            Open Your Dashboard
          </Button>
        </Container>
      </Body>
    </Html>
  );
}

Avoid React Entity Escaping Issues

When writing raw Handlebars syntax directly into JSX (such as {{#if foo}}), the React compiler will escape quotes inside HTML attributes, transforming " into &quot;. SES Handlebars parsers do not evaluate &quot; correctly, causing template compilation failures. Always inspect your rendered HTML output before syncing, ensuring template tags contain standard quotes.

Deploying Templates via GitHub Actions

Have your build:templates script write dist/sendops.json plus every template file it references. The SendOps GitHub Action takes that bundle directory through its dir input:

# .github/workflows/deploy-templates.yml
name: Sync Email Templates
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci && npm run build:templates
      - name: Push Templates to SendOps & SES
        uses: AltaCoda/sendops-push@v1
        with:
          api_key: ${{ secrets.SENDOPS_API_KEY }}
          dir: ./dist
          dry_run: ${{ github.event_name == 'pull_request' }}

The manifest must name the HTML files and supply the subjects and test data your templates need. Follow the bundle format and CI instructions for a complete export. For React rendering, see React Email on Amazon SES.


Step 4: Build the Workflow in SendFlow

With audience segments and templates ready, you can assemble the entire journey. You can build workflows visually on the SendOps drag-and-drop canvas, or you can write them directly in SendFlow, our plain-text workflow language that can be checked into Git.

Here is our complete trial onboarding sequence written in SendFlow:

workflow "Trial onboarding" v1 {
  enter on segment "trial-started"

  exit "left-trial" when attr.plan != "trial" or attr.plan is unknown

  send "welcome" transactional

  wait 2d

  if not exists(open where template = "welcome") {
    send "welcome-reminder" via topic "onboarding"
    wait 2d
  }

  send "activation-tips" via topic "onboarding"

  wait up to 7d until count(activity.login within 7d) >= 2 {
    timeout: send "need-a-hand" via topic "onboarding"
  }
}

Dissecting the Workflow Engine Logic

  • Named Exits (exit "left-trial" when ...): The documented check runs before every step. This stops the next step when the plan is no longer trial or is missing. Do not assume it interrupts an existing wait immediately, or count all exits as paid conversions.
  • Consent Lanes (transactional vs via topic "onboarding"): The welcome uses the transactional lane and is exempt from topic subscription checks. That does not guarantee delivery: suppression, frequency caps, send windows, approval, and SES constraints still matter. Use this lane for appropriate lifecycle content. Follow-up nudges use the onboarding topic and respect its consent checks.
  • Dynamic Wait Blocks (wait up to 7d until ...): Instead of forcing a rigid delay, the workflow monitors the contact’s activity stream in real time. When the condition evaluates true, execution moves forward without waiting for the full timeout. If the timer expires after 7 days without hitting the threshold, the timeout: branch fires the rescue email.

Production Execution Settings

Place settings inside the workflow block, before its first step. For new signups, keep enrollment forward-only:

send window 9am-6pm in contact timezone
frequency cap 4 per 7d
reentry once
enroll forward

A send window can delay the welcome too. Frequency-capped sends are skipped, not queued, including transactional sends. Use enroll existing since 30d only when you intentionally want to backfill existing matches, and review the enrollment estimate first.

Setting reentry once ensures a contact can only ever be enrolled in this specific onboarding sequence a single time, even if upstream application bugs emit multiple enrollment events. To explore all syntax capabilities, read the SendFlow workflow reference or explore our product workflows overview.


Step 5: Add an Activity-Triggered Branch

Workflows are not limited to segment enrollment; you can trigger workflows instantly from incoming application activities. Use an activity trigger when a recorded product action should start the journey; choose each message’s consent lane separately.

workflow "Pro onboarding" v1 {
  enter on activity.purchase where attr.plan = "pro"

  send "upgrade-confirmation" transactional
  wait 1d
  send "pro-onboarding-vip" via topic "product-updates"
}

Notice that the where clause inside enter on activity.purchase where attr.plan = "pro" evaluates conditions against the contact’s profile, not the event payload. If you need to filter on the data payload of the activity itself, use aggregation checks:

workflow "High-value thank you" v1 {
  enter on segment "active-customers"

  if exists(activity.purchase where amount > 100 within 1h) {
    send "vip-thank-you" via topic "rewards"
  }
}

For details on trigger criteria, visit the SendOps triggers reference.


Step 6: Reach Beyond Email with Webhooks

Modern onboarding flows often require orchestrating actions outside the inbox, such as notifying account managers in a CRM or triggering mobile push notifications.

SendOps allows you to place webhook steps directly in your workflow. Webhook steps feature built-in failure recovery so down upstream services do not block journeys:

call webhook "mobile-push" as "cart-reminder" {
  failed: send "cart-reminder" via topic "marketing"
}

A 2xx response continues along the main path. A 4xx response other than 408 or 429 takes the failed: branch without the standard retry sequence. Server errors, timeouts, and connectivity failures follow the documented retry schedule before failure; the overall deadline is 30 minutes. The fallback email still goes through approval, consent, suppression, and frequency checks. Create the mobile-push endpoint under Connections → Webhooks, with Use for: Workflows, before using this step.

To see this pattern in action, view the docs on push notification workflows.


Step 7: Rehearse in Shadow Mode, Then Go Live

Testing email workflows in production is traditionally risky. If you make a mistake in your logic, you can easily spam your entire customer base with duplicate messages. SendOps solves this with shadow mode.

In shadow mode, the workflow runs against live production data and real clocks. It evaluates segments, triggers, branches, and suppression rules, but it never dispatches live messages to SES or mutates contact data. Every send action is simulated and logged.

Validate the saved workflow in SendOps first. Replace flow_9281a below with its actual workflow ID and create the internal-testers List before using it as a cohort. Rehearse with contacts covering each branch, including a missing plan, an upgrade, and an opt-out.

Activate shadow mode via the API:

curl -X POST https://api.sendops.dev/v1/workflows/flow_9281a/activate \
  -H "Authorization: Bearer $SENDOPS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "shadow",
    "cohort": {
      "list": "internal-testers"
    }
  }'

Verifying the Shadow Audit Log

While running in shadow mode, inspect the node execution breakdown in your dashboard:

  • Enrolled: Contacts actually enrolled in the rehearsal, after applying its cohort and enrollment rules.
  • Would Send: Recorded send actions across all send nodes, including those consent would filter; this is not a unique-contact count.
  • Filtered by Consent: The subset of those recorded sends that consent and frequency rules would drop.

For the observed rehearsal, subtract filtered sends from recorded sends:

Rehearsal sends after filtering = Would send − Filtered by consent.

This is a rehearsal result, not a guarantee of future live volume. Timing, audience state, and consent can change. Going live also clears the rehearsal cohort and reapplies any configured backfill, so inspect the unrestricted enrollment estimate before proceeding.

Once the scope and branch results are correct, switch to live:

curl -X POST https://api.sendops.dev/v1/workflows/flow_9281a/go-live \
  -H "Authorization: Bearer $SENDOPS_API_KEY"

Switching to live ends the shadow runs, clears the cohort restriction, and starts live enrollment. For a workflow with send steps, send approval is forced on; review the Approvals panel before expecting messages to leave. Contacts that participated in the shadow run are eligible to enroll in the live workflow. Learn more about testing workflows in our guide to testing email workflows and the shadow mode documentation.


More Drip Patterns You Can Adapt

Once your onboarding sequence is live, you can reuse the same infrastructure for other key customer lifecycle moments.

1. Win-Back Campaign for Churned Users

Target users whose most recent recorded purchase was over two months ago. This intentionally excludes contacts with no purchase history; it does not measure other kinds of engagement. Pair with our guide on win-back email campaigns:

last(activity.purchase) < now - 60d
  and subscribed to "win-back"
  and not suppressed

2. Subscription Renewal Reminders

Enroll users relative to a future account property, giving them adequate notice before their card is charged:

workflow "Renewal reminder" v1 {
  enter on 3d before attr.renewal_date
  enroll existing since 30d

  send "upcoming-renewal" via topic "billing"
}

3. Returning Website Visitors

Using SendOps Website Identity and Google Tag Manager, you can capture anonymous web sessions and trigger workflows when identified contacts revisit pricing pages:

workflow "Pricing follow-up" v1 {
  enter on activity.site_visit where exists(activity.site_visit where path = "/pricing" within 1h)

  wait 2h
  if not exists(activity.purchase within 2h) {
    send "pricing-faq" via topic "sales"
  }
}

Promote the path activity property before using this filter, and match your site’s actual path (including any trailing slash). The condition checks the contact’s recent activity history, not the payload of the triggering visit.

4. Workflow A/B Testing

Test two distinct onboarding strategies and measure retention:

workflow "Onboarding experiment" v1 {
  enter on segment "new-signups"

  split {
    45%: {
      send "onboarding-self-serve" via topic "onboarding"
    }
    45%: {
      send "onboarding-book-demo" via topic "onboarding"
    }
    10%: {
      // Holdout control group receives no email
    }
  }
}

For more conversion-focused patterns, explore our guide on building a trial conversion email workflow.


Monitoring Deliverability Once It Is Running

Running automated drips on raw Amazon SES requires keeping a close eye on your sending reputation. When an automated drip sends to bad email addresses or triggers spam complaints, your entire AWS account reputation is at risk.

SendOps connects directly to the Amazon SES event stream, giving you continuous visibility into delivery health:

  • Funnel Visualization: Review per-node metrics on the visual canvas to see exactly where users drop off, convert, or trigger timeouts.
  • Contact Timelines: Inspect full, chronologically ordered run logs for any contact to see exactly why they took a specific branch or why a send was skipped.
  • Configured Deliverability Alerts: Choose thresholds and notification destinations, then check that notifications reach the responsible team. Monitor AWS reputation metrics alongside your operational reports; an alert does not guarantee warning before an AWS enforcement action.

SendOps connects to your AWS account and sends through your SES resources. Contacts, consent, events, and campaign content are held by SendOps. You can read more about how this architecture works in our deep-dive on AWS SES drip campaigns without per-contact pricing.


Summary & Next Steps

Building an automated drip campaign on Amazon SES does not require maintaining a complex custom web of Lambdas, DynamoDB tables, and Step Functions.

To build and launch a lifecycle workflow:

  1. Ingest Application Telemetry: Emit activities via the REST API using "dedup_mode": "once" for durable lifecycle events.
  2. Segment Your Audience: Write precise SendQL rules with guards for unknown properties and absence conditions.
  3. Build Strict-Safe Templates: Use React Email with optional-value fallbacks, provide required fields, test rendering, and capture rendering-failure events.
  4. Define Your Logic: Assemble your branching, timers, and exit criteria in SendFlow or the visual builder.
  5. Incorporate Fallbacks: Wrap cross-channel webhooks in retry and failure handlers.
  6. Run Shadow Mode: Test execution against live contacts without sending live emails.
  7. Promote to Production: Go live and monitor real-time delivery and reputation metrics.

Ready to build workflows that send through your own SES account? Start free with SendOps to connect your SES account in minutes, or explore the SendFlow reference to write your first workflow.