Skip to main content
Personalized emails outperform generic ones. Recipients are more likely to open, read, and act on emails that address them by name, reference their specific activity, and show content relevant to their situation. Lettr’s template language provides merge tags, conditionals, loops, and filters that let you build a single template that renders differently for each recipient.

Merge Tags

Merge tags are placeholders in your template that get replaced with recipient-specific data at send time. They use double curly braces.

Basic Substitution

<p style="font-size: 16px; color: #4a4a68;">
  Hello {{ name }},
</p>
<p style="font-size: 16px; color: #4a4a68;">
  Your account ({{ email }}) has been active since {{ signup_date }}.
</p>
Pass the substitution data in your API request:
await fetch('https://app.lettr.com/api/emails', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer your-api-key',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    from: 'hello@mail.example.com',
    to: 'alice@example.com',
    templateId: 'welcome-email',
    substitutionData: {
      name: 'Alice',
      email: 'alice@example.com',
      signup_date: 'January 15, 2025'
    }
  })
});

Default Values

Always provide defaults for personalization fields. An email that reads “Hello ,” because the name field is missing looks broken.
<!-- Provide fallback values with the 'or' keyword -->
<p>Hello {{ name or 'there' }},</p>
<p>Your {{ plan_name or 'current' }} plan includes {{ email_limit or '1,000' }} emails per month.</p>
Never assume all substitution data will be present. Missing fields render as empty strings by default, which creates gaps in your content. Use the or keyword on every variable that appears in visible text.

HTML Escaping

By default, merge tags are HTML-escaped to prevent cross-site scripting. If you need to render trusted HTML content, use triple curly braces:
<!-- HTML-escaped (safe for user-provided data) -->
<p>{{ user_message }}</p>

<!-- Unescaped (use only with trusted, system-generated content) -->
<div>{{{ custom_html_block }}}</div>
Only use triple curly braces ({{{ }}}) with content you control — never with user-submitted data. Unescaped content can inject arbitrary HTML into your emails.

Conditional Content

Show different content to different recipients based on their attributes, plan level, activity, or any other data you pass.

Basic Conditionals

{{if premium}}
  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">
    <tr>
      <td style="padding: 16px; background-color: #eef2ff; border-radius: 8px;">
        <p style="margin: 0; color: #4338ca; font-weight: bold;">Premium Member</p>
        <p style="margin: 8px 0 0; color: #4a4a68;">
          Enjoy free priority support and advanced analytics.
        </p>
      </td>
    </tr>
  </table>
{{else}}
  <table role="presentation" width="100%" cellpadding="0" cellspacing="0">
    <tr>
      <td style="padding: 16px; background-color: #f3f4f6; border-radius: 8px;">
        <p style="margin: 0; color: #4a4a68;">
          <a href="https://example.com/upgrade" style="color: #6366F1; text-decoration: underline;">
            Upgrade to Premium
          </a>
          for priority support and advanced analytics.
        </p>
      </td>
    </tr>
  </table>
{{end}}

Multi-Branch Conditionals

Use {{elseif}} for more than two branches:
{{if plan == 'enterprise'}}
  <p style="color: #4a4a68;">
    Your Enterprise plan includes unlimited emails and dedicated support.
  </p>
{{elseif plan == 'pro'}}
  <p style="color: #4a4a68;">
    Your Pro plan includes 50,000 emails per month.
    <a href="https://example.com/upgrade" style="color: #6366F1;">Upgrade to Enterprise</a>
    for unlimited sending.
  </p>
{{elseif plan == 'free'}}
  <p style="color: #4a4a68;">
    Your Free plan includes 3,000 emails per month.
    <a href="https://example.com/upgrade" style="color: #6366F1;">Upgrade your plan</a>
    to send more.
  </p>
{{else}}
  <p style="color: #4a4a68;">
    <a href="https://example.com/pricing" style="color: #6366F1;">Choose a plan</a>
    to get started.
  </p>
{{end}}

Practical Use Cases for Conditionals

Use CaseConditionEffect
Show upgrade CTA to free users{{if plan == 'free'}}Display upgrade banner only for free-tier recipients
Trial expiry warning{{if days_remaining <= 3}}Show urgent renewal message for expiring trials
Locale-specific content{{if locale == 'de'}}Show German-language support links for German users
Feature announcement{{if feature_enabled}}Show feature details only to users who have access
Empty state handling{{if items_count == 0}}Show “no activity” message instead of an empty list

Dynamic Loops

Use {{each}} to iterate over arrays of data — order items, activity logs, recommendations, or any list.

Order Items

<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
  <tr>
    <td style="padding: 0 0 8px; border-bottom: 2px solid #1a1a2e;">
      <table role="presentation" width="100%">
        <tr>
          <td style="font-weight: bold; color: #1a1a2e; font-size: 14px;">Item</td>
          <td style="font-weight: bold; color: #1a1a2e; font-size: 14px; text-align: center;">Qty</td>
          <td style="font-weight: bold; color: #1a1a2e; font-size: 14px; text-align: right;">Price</td>
        </tr>
      </table>
    </td>
  </tr>
  {{each items}}
  <tr>
    <td style="padding: 12px 0; border-bottom: 1px solid #e5e7eb;">
      <table role="presentation" width="100%">
        <tr>
          <td style="color: #4a4a68; font-size: 14px;">{{ name }}</td>
          <td style="color: #4a4a68; font-size: 14px; text-align: center;">{{ quantity }}</td>
          <td style="color: #4a4a68; font-size: 14px; text-align: right;">{{ price }}</td>
        </tr>
      </table>
    </td>
  </tr>
  {{end}}
  <tr>
    <td style="padding: 12px 0;">
      <table role="presentation" width="100%">
        <tr>
          <td style="font-weight: bold; color: #1a1a2e; font-size: 16px;">Total</td>
          <td style="font-weight: bold; color: #1a1a2e; font-size: 16px; text-align: right;">
            {{ order_total }}
          </td>
        </tr>
      </table>
    </td>
  </tr>
</table>
With this substitution data:
{
  "items": [
    { "name": "Widget Pro", "quantity": "2", "price": "$29.99" },
    { "name": "Adapter Cable", "quantity": "1", "price": "$9.99" },
    { "name": "Carrying Case", "quantity": "1", "price": "$19.99" }
  ],
  "order_total": "$89.96"
}

Combining Loops with Conditionals

{{each notifications}}
  <tr>
    <td style="padding: 12px 0; border-bottom: 1px solid #e5e7eb;">
      {{if type == 'alert'}}
        <p style="margin: 0; color: #dc2626; font-weight: bold;">⚠ {{ message }}</p>
      {{elseif type == 'success'}}
        <p style="margin: 0; color: #16a34a;">✓ {{ message }}</p>
      {{else}}
        <p style="margin: 0; color: #4a4a68;">{{ message }}</p>
      {{end}}
      <p style="margin: 4px 0 0; color: #9ca3af; font-size: 12px;">{{ timestamp }}</p>
    </td>
  </tr>
{{end}}

Per-Recipient Personalization at Scale

When sending to multiple recipients in a single API call, pass individual substitution data for each recipient:
await fetch('https://app.lettr.com/api/emails', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer your-api-key',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    from: 'billing@mail.example.com',
    to: [
      {
        email: 'alice@example.com',
        substitutionData: {
          name: 'Alice',
          plan: 'pro',
          usage: '12,450',
          limit: '50,000',
          invoice_url: 'https://example.com/invoices/inv_001'
        }
      },
      {
        email: 'bob@example.com',
        substitutionData: {
          name: 'Bob',
          plan: 'starter',
          usage: '4,820',
          limit: '5,000',
          invoice_url: 'https://example.com/invoices/inv_002'
        }
      }
    ],
    templateId: 'monthly-usage-report'
  })
});
Batch sending with per-recipient substitution data is the most efficient way to send personalized emails at scale. One API call with 200 recipients and individual data is much faster than 200 separate calls.

Personalization Strategy

What to Personalize

Not everything needs personalization. Focus on elements that make the email feel relevant and useful.
ElementImpactExample
Recipient nameMedium”Hello Alice” vs “Hello”
Account-specific dataHighUsage stats, plan details, recent activity
Contextual actionsHigh”Upgrade your plan” vs “Manage your Enterprise account”
Timing referencesMedium”Your trial ends in 3 days” vs “Your trial is ending soon”
Product recommendationsHighItems based on purchase history or browsing behavior

What Not to Personalize

ElementWhy
Subject lines with first names onlyOverused; no longer increases open rates for most audiences
Location unless action-relevant”Hello from Seattle!” feels surveillance-like if not contextually useful
Personal details you shouldn’t knowAvoid revealing data the recipient didn’t knowingly provide

Testing Personalized Templates

Preview with Sample Data

Before sending, test your template with different substitution data to verify all branches render correctly.
// Test case 1: Premium user with items
const premiumData = {
  name: 'Alice',
  premium: true,
  plan: 'enterprise',
  items: [
    { name: 'Item A', quantity: '1', price: '$10.00' }
  ]
};

// Test case 2: Free user with no items
const freeData = {
  name: '',  // Test missing name → should fall back to default
  premium: false,
  plan: 'free',
  items: []  // Test empty loop
};

// Test case 3: Edge case — very long values
const edgeCaseData = {
  name: 'Alexandrina Konstantinopolskaya-Featherington',
  premium: true,
  plan: 'pro',
  items: Array(20).fill({ name: 'Item', quantity: '1', price: '$5.00' })
};
Pay special attention to empty states. What does your template look like when items is an empty array? When name is missing? When a conditional branch has no matching case? Every possible state should render cleanly.

Common Mistakes

Forgetting the or keyword results in empty strings when data is missing. “Hello ,” or “Your plan includes emails per month” looks broken. Always add fallbacks: {{ name or 'there' }}.
Using “Hey !” in every subject line has diminishing returns. Recipients learn to ignore it, and some find it off-putting. Save name personalization for the email body and keep subject lines focused on the content.
If you have 4 plan tiers, you need to test all 4 renderings plus the else fallback. A broken conditional in a branch you didn’t test will surprise you in production.
Triple curly braces ({{{ }}}) skip HTML escaping. If user-submitted data contains HTML tags or scripts, it renders raw in the email. Only use triple braces with system-generated, trusted content.
Testing always with name: 'Test User' and plan: 'pro' misses edge cases. Vary your test data to cover missing fields, empty arrays, long strings, and unusual characters.