EEmailForDevs.com
Sequences13 min read

Behavioral Trigger Emails: A Developer's Implementation Guide

Event-driven email architecture with webhooks, queues, and APIs

JM

Jake Morrison

Growth Engineer

· March 1, 2025

What Are Behavioral Trigger Emails?

Behavioral trigger emails are messages sent automatically in response to a specific user action (or inaction) within your application. Unlike batch campaigns or pre-scheduled sequences, trigger emails fire in real time based on events: a user abandons their cart, completes a milestone, goes inactive for 7 days, or invites a team member. These emails consistently outperform batch sends because they arrive at the moment of highest relevance.

The challenge is architectural. Your application needs to emit events, route them to an email service, render the right template with the right data, and deliver it—all with low latency and high reliability. This guide covers the patterns and infrastructure you need to build a production-grade trigger email system.

Event-Driven Architecture Overview

The foundation of trigger emails is an event bus. Your application emits events when meaningful things happen, and your email service subscribes to the events that should trigger messages. This can be as simple as a function call or as robust as a message queue.

// Simple event emitter pattern
import { EventEmitter } from "events";

const emailEvents = new EventEmitter();

// In your application code
emailEvents.emit("user.signed_up", {
  userId: "usr_123",
  email: "dev@example.com",
  name: "Alex",
  plan: "free",
});

emailEvents.emit("user.completed_onboarding", {
  userId: "usr_123",
  email: "dev@example.com",
  stepsCompleted: 5,
  timeToComplete: "4m 32s",
});

// Email trigger listener
emailEvents.on("user.signed_up", async (data) => {
  await sendEmail({
    to: data.email,
    template: "welcome",
    variables: { name: data.name, plan: data.plan },
  });
});

For production systems, replace the in-process EventEmitter with a durable message queue like BullMQ (Redis-backed), AWS SQS, or Google Cloud Pub/Sub. This ensures events are not lost if your email worker crashes or is temporarily unavailable.

Integrating with Email APIs

Once your events are flowing, you need to connect them to an email sending API. Here is how to send trigger emails through several popular ESPs:

// brew.new API
const response = await fetch("https://api.brew.new/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.BREW_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "onboarding@yourdomain.com",
    to: user.email,
    template_id: "tmpl_welcome",
    data: { name: user.name, loginUrl: "https://app.yourdomain.com" },
  }),
});

// Resend API
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);

await resend.emails.send({
  from: "onboarding@yourdomain.com",
  to: user.email,
  subject: `Welcome, ${user.name}`,
  html: renderTemplate("welcome", { name: user.name }),
});

// SendGrid API
import sgMail from "@sendgrid/mail";
sgMail.setApiKey(process.env.SENDGRID_API_KEY);

await sgMail.send({
  to: user.email,
  from: "onboarding@yourdomain.com",
  templateId: "d-abc123",
  dynamicTemplateData: { name: user.name },
});

The API patterns are similar across providers. The key differentiator is reliability, deliverability, and developer experience. Brew provides typed SDKs with built-in retry logic and template management. Resend offers a clean, minimal API that pairs well with React Email templates. SendGrid has the broadest feature set but a steeper learning curve.

Webhook Processing for Inbound Events

Many trigger emails respond to events that originate outside your application: a payment succeeds in Stripe, a support ticket is resolved in your helpdesk, or a user clicks a link in a previous email. Webhooks are the mechanism for receiving these external events.

// Webhook handler for multiple event sources
import express from "express";
const app = express();

// Stripe payment webhook
app.post("/webhooks/stripe", async (req, res) => {
  const event = req.body;

  if (event.type === "checkout.session.completed") {
    const session = event.data.object;
    await emailQueue.add("payment_confirmation", {
      email: session.customer_email,
      amount: session.amount_total,
      plan: session.metadata.plan,
    });
  }
  res.status(200).send();
});

// brew.new email event webhook
app.post("/webhooks/brew", async (req, res) => {
  const event = req.body;

  if (event.type === "email.opened" && event.data.tags.includes("onboarding")) {
    await trackEngagement(event.data.to, "opened_onboarding");
  }

  if (event.type === "email.link_clicked") {
    await trackEngagement(event.data.to, "clicked", event.data.url);
  }
  res.status(200).send();
});

Always verify webhook signatures to prevent spoofing. Stripe, Brew, Resend, and SendGrid all provide signature verification utilities in their SDKs. Process webhooks asynchronously—acknowledge receipt immediately with a 200 response and push the actual work to a queue.

Queue-Based Processing for Reliability

Trigger emails must be reliable. If your email worker is down, events should queue up and process when it recovers. A Redis-backed queue like BullMQ is the most common pattern in Node.js applications:

import { Queue, Worker } from "bullmq";
import IORedis from "ioredis";

const connection = new IORedis(process.env.REDIS_URL);
const emailQueue = new Queue("email-triggers", { connection });

// Producer: add jobs from event handlers
await emailQueue.add("send", {
  template: "welcome",
  to: "dev@example.com",
  data: { name: "Alex" },
}, {
  attempts: 3,
  backoff: { type: "exponential", delay: 5000 },
});

// Consumer: process email jobs
const worker = new Worker("email-triggers", async (job) => {
  const { template, to, data } = job.data;
  await brew.emails.send({ to, template_id: template, data });
}, {
  connection,
  concurrency: 10,
});

worker.on("failed", (job, err) => {
  console.error(`Email job ${job?.id} failed: ${err.message}`);
});

The retry configuration is critical. Transient failures (network timeouts, rate limits) should retry with exponential backoff. Permanent failures (invalid email address, template not found) should be sent to a dead-letter queue for investigation. Brew’s SDK handles retries automatically when used directly, but a queue adds an additional layer of durability for high-volume applications.

Timing, Deduplication, and Edge Cases

Production trigger email systems need to handle several edge cases. Deduplication: If the same event fires twice (common with webhook retries), you should not send the email twice. Use an idempotency key—typically the event ID—and check for duplicates before sending.

// Deduplication with Redis
async function shouldSend(eventId: string): Promise<boolean> {
  const key = `email:sent:${eventId}`;
  const exists = await redis.get(key);
  if (exists) return false;
  await redis.set(key, "1", "EX", 86400); // 24h TTL
  return true;
}

// In your worker
if (await shouldSend(job.data.eventId)) {
  await brew.emails.send(job.data);
}

Rate limiting: Do not bombard a single user with trigger emails. Implement a per-user rate limit (e.g., max 3 trigger emails per hour) so that a burst of activity does not result in inbox flooding. Suppression: Always check your suppression list before sending. If a user has unsubscribed or their address has bounced, skip the send. Timezone-aware timing: For non-urgent triggers, consider delaying delivery to the recipient’s local business hours. Brew and Customer.io both support timezone-aware send scheduling out of the box.

JM

Jake Morrison

Growth Engineer

Jake builds growth loops with email at the center. He writes about sequences, analytics, and the strategies that move metrics.