Now that you’ve set up authentication for your email domains, you’re almost ready to start sending your transactional emails.
In this next section, we’ll walk you through best practices for crafting and sending transactional messages that land in the inbox, stay on-brand, and work across user segments and locales.
How to manage ongoing email deliverability
While the technical setup may be done, you’ll still have to stay on top of maintaining deliverability as you scale up your email output.
In addition to closely monitoring your email performance through tools like Google Postmaster—which provides insights into spam rate, domain reputation, and delivery errors—there are several other tips to help ensure your transactional emails consistently reach the inbox instead of getting flagged or ignored.
Use a subdomain
Sending transactional emails from a subdomain helps prevent any chance of damaging your primary domain's reputation, which can stop mission-critical emails from getting to your users.
Furthermore, you should also send your marketing emails from a subdomain, but make sure to always send transactional emails from a different subdomain, as marketing emails are more likely to flagged.
While it may be tempting to use a look-a-like domain, avoid using these, as spammers often phish from look-a-like domains and email providers penalize this. Plus, strategic subdomain names can actually help communicate the email's purpose.
For each new subdomain, you’ll need to warm it up by slowly increasing sending volume over time. You can do this manually or use a paid service–read all about it here.
Don't use no-reply emails
No-reply emails indicate to mailbox providers that the email is one-way communication, which decreases trust. It signals to inboxes that they cannot provide feedback on your emails (like reporting spam). Additionally, receiving replies is shown to increase for your domain health.
Avoid link and open tracking when possible
While link tracking and open tracking are great to measure the impact of marketing emails, they can actually hurt your deliverability for transactional emails like notifications and magic links if the link doesn't contain your domain.
Keep emails small and accessible
Gmail has a size limit of 102KB for each email message. Once that limit is reached, the remaining content is clipped. Including a plain text version of your email is also good practice as it ensures that your message is accessible to all recipients, including those who have email clients that don't support HTML. To help with both email size and accessibility, we recommend avoiding emails with a lot of images.
Maintain a clean email list
You should only send to recipients who've asked to be sent to. Don't send to unsubscribers, those who haven't engaged with your content, or those who've marked your previous emails as spam.
Additionally, you should set up workflows to track bounces (hard or soft) or spam complaints and automatically remove those recipients from receiving future emails.
Send consistently
Mailbox providers are suspicious when you suddenly change the volume of sending. If you want to send thousands of emails in a few months, you need to warm up your domain by sending them regularly ahead of time. Otherwise, you risk a large bounce rate and a big hit to your reputation, which can be hard to recover from in the future.
Abstracting email with notifications infrastructure
By now, you've selected an ESP, set up email authentication, and have deliverability best practices in mind. Ready to send?
Kind of. At this point, many choose to use an ESP directly. And it seems straightforward initially:
// Day 1: Simple enough!
await sendgrid.send({
to: user.email,
subject: "Welcome to our app!",
html: welcomeEmailHtml,
});
While this technically works, you're only solving the "send email" problem—not the broader challenge of managing user communications across your entire product.
Building in production-ready requirements quickly reveals the complexity that often requires significant engineering effort:
// Month 6: The reality of production notifications
try {
// Check user preferences
if (!user.preferences.emailEnabled) return;
// Handle timezone-aware sending
if (shouldDelay(user.timezone)) {
await queue.schedule(emailData, getOptimalSendTime(user));
return;
}
// Template selection based on user segment
const template = getTemplateForUser(user);
// Localization
const locale = user.locale || "en";
const translations = await loadTranslations(locale);
// Merge data with fallbacks
const emailData = {
to: user.email,
subject: translations[template].subject,
templateId: templateIds[template],
dynamicTemplateData: {
...user,
...eventData,
translations,
unsubscribeUrl: generateUnsubscribeToken(user.id),
},
};
// Send with retry logic
const result = await retry(() => sendgrid.send(emailData), {
retries: 3,
backoff: "exponential",
});
// Track event
await analytics.track("email_sent", {
userId: user.id,
template,
messageId: result.messageId,
});
} catch (error) {
// Handle various failure modes
if (error.code === "INVALID_EMAIL") {
await markEmailInvalid(user.id);
} else if (error.code === "RATE_LIMITED") {
await queue.delay(emailData);
} else {
await alertOncall("Email send failed", error);
}
}
This is just email. Now multiply this complexity across push notifications, SMS, Slack, in-app messages, and every other channel your users expect.
What is notification infrastructure?
Notifications infrastructure platforms abstract away the complexity of multi-channel messaging, providing a unified API for all your communication needs. Think of it as the layer between your application logic and the various delivery services.

To send an email via any provider across any channel, with error handling, retries, analytics, and fallbacks, it just takes a single API call:
import Knock from "@knocklabs/node";
const client = new Knock({
apiKey: process.env["KNOCK_API_KEY"], // This is the default and can be omitted
});
const response = await client.workflows.trigger("key", {
recipients: ["dr_grant", "dr_sattler", "dr_malcolm"],
actor: "mr_dna",
cancellation_key: "isla_nublar_incident_1993",
data: {
affected_areas: ["visitor_center", "raptor_pen", "trex_paddock"],
attraction_id: "paddock_rex_01",
evacuation_protocol: "active",
message: "Life finds a way",
severity: "critical",
system_status: "fences_failing",
},
tenant: "ingen_isla_nublar",
});
console.log(response.workflow_run_id);
The benefits of notification infrastructure
Beyond that, though, there are several other tangible benefits to using notifications infrastructure on top of your ESP of choice:
- Faster development: The complexity of error handling, retries, analytics, fallbacks, and more is managed for you, so you can ship advanced notification features in hours, not weeks.
- Better user experience: Your users experience notifications as a cohesive system where every message maintains consistent branding and timing across email, push, and in-app channels. The infrastructure automatically respects user preferences, ensuring people receive only the notifications they've explicitly requested. Intelligent batching prevents notification fatigue by grouping related updates into digestible summaries, while rich in-app notification feeds give users a central place to review all their notifications with proper read states and persistent history.
- Operational excellence: Engineering teams gain centralized monitoring that provides visibility across all channels from a single dashboard, making it simple to debug delivery issues. Version control for templates brings code-like rigor to content management with review processes and rollback capabilities. Built-in A/B testing lets you optimize engagement without custom implementation, while team collaboration happens without code deploys—product managers and marketers can iterate on content without waiting for engineering releases.
- Cost optimization: Automatic provider failover protects against vendor outages or price hikes by seamlessly routing through backup providers. The infrastructure optimizes channel selection, sending urgent alerts via push while routing less critical updates to cheaper email. Your engineering team spends less time maintaining notification code and debugging integrations. This systematic approach also reduces the support burden, as consistent delivery and clear preference management result in fewer confused users contacting your support team.
When to invest in notification infrastructure
There are several reasons certain companies may not need a notification infrastructure solution. They may send a very low volume of notifications where it's not worth the investment in more advanced tooling. Or perhaps they are only sending simple notifications through one channel without the need for advanced features.
Whatever the case, it's important to keep an eye on the needs of your business and how much time your engineering team is using to support notifications.
There are several moments where it might start to make sense to invest in notifications infrastructure.
- You're developing complex workflows. Building your own transactional emails works well when you have just a few triggers and they are sent immediately on trigger. But when trigger volume grows or you have to add in delays or scheduling, conditional sending (think "only email if user hasn't opened the mobile app in 3 days"), or multi-step sequences (think "send in-app notification → wait 12 hours → if not viewed, send email"), additional infrastructure is a good idea. - You're sending across multiple channels. In this guide, we’ve focused on transactional emails—but if you're also using triggers to send Slack messages, push notifications, or SMS, you would benefit from additional infrastructure to orchestrate the different channels. - You're facing challenges scaling. A notification infrastructure platform is purpose-built to handle all the advanced use cases that come with scaling your notifications, from preference management complexity to template versioning to end-to-end observability and so much more.
Effectively, the investment in abstraction pays dividends through:
- Reduced complexity as you add channels
- Improved deliverability through best practices
- Better user experience via preferences and batching
- Faster iteration on notification design
- Unified analytics across all touchpoints
Your users don't think in terms of channels—they just want timely, relevant updates, however they prefer to receive them. The right abstraction layer makes that possible without drowning your team in integration complexity. Whether you build that abstraction yourself or use a platform like Knock, the key is recognizing that notifications are a core product feature deserving of proper infrastructure.
How to use templates in transactional email
Modern transactional email demands more than simple string substitution. You want your transactional emails to evolve from basic variables to content that handles componentization, localization, and dynamic data injection, all while maintaining consistency and preventing errors.

Beyond that, designing visually attractive and functional emails that are responsive across devices can be another challenge. Frameworks like MJML and React Email were created to help developers craft better end products, but those tools have a steep learning curve and are only accessible to people with certain technical skills. Without frameworks, email creators need to either be guided by an editor interface that applies best practices automatically, or they need to wade through the many inconsistent rendering behaviors of modern email clients.
However, many legacy email solutions that use a visual editor stop short of implementing a full email templating system that can be customized to meet the needs of an organization. Modern transactional email requires a componentized approach that mirrors the best practices of frontend development: reusable components, centralized styling, and systematic design tokens.
At its core, componentized email templates follow a simple hierarchy:
<!-- Email Layout (the frame) -->
<html>
<head>
{{ styles }}
</head>
<body>
{{ header_partial }}
<div class="content">
{{ content }}
<!-- Your template content goes here -->
</div>
{{ footer_partial }}
</body>
</html>
Your individual email templates then focus solely on their unique content:
<!-- Password Reset Template -->
<h1>Reset your password</h1>
<p>Hi {{ user.name }},</p>
<p>Click the link below to reset your password:</p>
<a href="{{ reset_link }}" class="button button-primary"> Reset Password </a>
This separation delivers immediate benefits:
- Consistency: Update your header once, it changes everywhere
- Maintainability: No more hunting through 50 templates to update a logo
- Flexibility: Different layouts for different email types (transactional vs. digest)
How to build a partial system
Partials are reusable chunks of email content that can be included across multiple templates, similar to components in modern web frameworks.

They extend componentization beyond layouts and eliminate duplication by extracting common elements like buttons, headers, or data displays into standalone files that accept parameters and render consistently wherever they're used.
Common use cases include...
Reusable UI components:
<!-- _button.liquid partial -->
<table class="button-wrapper">
<tr>
<td class="button {{ variant | default: 'primary' }}">
<a href="{{ url }}">{{ text }}</a>
</td>
</tr>
</table>
<!-- Usage in template -->
{% include '_button' url: product_url, text: "View Order", variant: "secondary"
%}
Dynamic content blocks:
<!-- _order_summary.liquid partial -->
<div class="order-summary">
<h3>Order #{{ order.number }}</h3>
<ul class="item-list">
{% for item in order.items %}
<li>
<span class="item-name">{{ item.name }}</span>
<span class="item-quantity">× {{ item.quantity }}</span>
<span class="item-price">{{ item.price | currency }}</span>
</li>
{% endfor %}
</ul>
<div class="order-total">Total: {{ order.total | currency }}</div>
</div>
Brand-specific components:
<!-- _company_signature.liquid partial -->
<div class="signature">
<p>{{ signoff_text | default: "Thanks," }}</p>
<p>{{ sender_name | default: company.default_sender }}</p>
{% if include_logo %}
<img src="{{ company.logo_url }}" alt="{{ company.name }}" />
{% endif %}
</div>
How to localize transactional email
Modern applications with global audiences require global notifications. The foundation of scalable localization is a well-structured translation key system.

Translation keys organize your content into hierarchical JSON structures, where each piece of text is identified by a unique key path. This allows the same template to render in multiple languages by swapping out the translation file.
// translations/en.json
{
"password_reset": {
"subject": "Reset your password",
"greeting": "Hi {{name}},",
"body": "We received a request to reset your password.",
"cta": "Reset Password",
"expiry_warning": "This link expires in {{hours}} hours.",
"footer": "If you didn't request this, please ignore this email."
}
}
// translations/es.json
{
"password_reset": {
"subject": "Restablecer tu contraseña",
"greeting": "Hola {{name}},",
"body": "Recibimos una solicitud para restablecer tu contraseña.",
"cta": "Restablecer Contraseña",
"expiry_warning": "Este enlace expira en {{hours}} horas.",
"footer": "Si no solicitaste esto, ignora este correo."
}
}
This template uses a translation filter (typically | t
) to look up the appropriate text for the user's locale, with support for variable interpolation to inject dynamic values like names or numbers into the translated strings.
<h1>{{ "password_reset.subject" | t }}</h1>
<p>{{ "password_reset.greeting" | t: name: user.first_name }}</p>
<p>{{ "password_reset.body" | t }}</p>
<a href="{{ reset_url }}"> {{ "password_reset.cta" | t }} </a>
<p class="warning">{{ "password_reset.expiry_warning" | t: hours: 24 }}</p>
How to personalize transactional emails
Modern transactional emails should aim to adapt their content based on the recipient's behavior, account status, and real-time data.

The most basic form of dynamic content uses conditional logic to show/hide sections. This email dynamically adjusts its content based on the user's account status, showing premium members their savings while prompting trial users to upgrade before their access expires:
{% if user.is_premium %}
<div class="premium-banner">
<h3>Thanks for being a Premium member!</h3>
<p>You saved {{ order.premium_discount | currency }} on this order.</p>
</div>
{% elsif user.trial_ending_soon %}
<div class="trial-banner">
<h3>Your trial ends in {{ user.trial_days_left }} days</h3>
<a href="{{ upgrade_url }}">Upgrade to keep your benefits</a>
</div>
{% endif %}
<!-- Dynamic CTAs based on user state -->
{% case user.onboarding_step %} {% when 'profile_incomplete' %}
<a href="{{ complete_profile_url }}" class="button"> Complete Your Profile </a>
{% when 'payment_pending' %}
<a href="{{ add_payment_url }}" class="button"> Add Payment Method </a>
{% else %}
<a href="{{ dashboard_url }}" class="button"> Go to Dashboard </a>
{% endcase %}
A common use is injecting usage statistics. Transactional emails are perfect vehicles for sharing relevant metrics and insights. This weekly summary email pulls in the user's actual usage data from your application, displaying personalized metrics and insights that help them understand their activity patterns and productivity trends:
<!-- Weekly summary email -->
<div class="usage-stats">
<h2>Your week at a glance</h2>
<div class="stat-grid">
<div class="stat">
<span class="value">{{ stats.tasks_completed }}</span>
<span class="label">Tasks completed</span>
<span class="change {{ stats.tasks_change_class }}">
{{ stats.tasks_change }}% vs last week
</span>
</div>
<div class="stat">
<span class="value">{{ stats.time_saved | format_duration }}</span>
<span class="label">Time saved</span>
</div>
</div>
<!-- Personalized insight -->
{% if stats.most_productive_day %}
<p class="insight">
💡 You're most productive on {{ stats.most_productive_day }}s. Consider
scheduling important work then!
</p>
{% endif %}
</div>
You’ll also see dynamic transactional email using behavioral data to make every email more relevant. This order confirmation enriches the basic receipt with personalized product recommendations, using the customer's browsing history and current discounts to suggest relevant items they might want to purchase next:
<!-- In an order confirmation email -->
{% if related_products.size > 0 %}
<div class="recommendations">
<h3>You might also like</h3>
<div class="product-grid">
{% for product in related_products limit: 3 %}
<div class="product-card">
<img src="{{ product.image_url }}" alt="{{ product.name }}" />
<h4>{{ product.name }}</h4>
<p>{{ product.price | currency }}</p>
<!-- Smart messaging based on user history -->
{% if product.id in user.previously_viewed %}
<span class="badge">Previously viewed</span>
{% elsif product.discount > 0 %}
<span class="badge">{{ product.discount }}% off</span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
This approach to template management delivers:
- Consistency through componentization
- Global reach through localization
- Relevance through dynamic content
- Maintainability through separation of concerns
With templates, partials, and dynamic content, your transactional emails become intelligent touchpoints that adapt to each user's context, language, and journey with your product.