Dynamic Templating

Updated · By the Pigeon Perch team

One template, many variations. Pigeon Perch templates run through a Handlebars rendering engine at send time, so a single campaign can show different content to different recipients based on their tags, location, custom properties, or any data you pass in.

How It Works

Templates are Handlebars templates. Anywhere you want to swap in data, use {{variable}} syntax. For conditional content, wrap it in {{#if}} or {{#hasTag}}. For lists, use {{#each}}. Helpers like {{formatDate}} transform values inline.

Existing templates keep working. {{first_name}} behaves identically to how it did before — the new engine is a strict superset.

Variables You Can Use

Contact

  • {{first_name}}, {{last_name}}, {{email}}, {{company}}, {{phone}}
  • {{city}}, {{state}}, {{country}}, {{postal_code}}, {{timezone}}
  • {{contact.tags}} — array of tag names
  • {{contact.engagementScore}} — number (0–100)
  • {{contact.customProperties.PLAN}} — any custom property you've set on the contact

Location Intelligence

  • {{location_interest_1_city}} — the contact's top-scored city (also _2_, _3_, etc.)
  • {{location_interests}} — full array you can loop over with {{#each}}

System

  • {{unsubscribe_url}}, {{preferences_url}} — per-recipient tokenized URLs

Custom Data

Campaigns have a Template data (JSON) field that accepts any structured value. Transactional API sends use the personalizations field in the same way. Access it in your template via {{data.key}}:

<p>Your promo code: {{data.promo_code}}</p>
<p>Offer ends {{formatDate data.offer_ends "MMM d, yyyy"}}</p>

Conditionals

{{#if contact.smsConsent}}
  <p>Reply STOP to manage your SMS preferences.</p>
{{else}}
  <p><a href="{{data.sms_optin_url}}">Opt in to SMS</a></p>
{{/if}}

{{#hasTag "vip"}}
  <p>VIP-exclusive content here.</p>
{{/hasTag}}

{{#ifEquals state "CA"}}
  <p>California-specific content.</p>
{{/ifEquals}}

{{#ifGreaterThan contact.engagementScore 70}}
  <p>Thanks for being one of our most engaged readers!</p>
{{/ifGreaterThan}}

Supported comparison helpers: ifEquals, ifNotEquals, ifGreaterThan, ifLessThan, ifContains, hasTag. Plain {{#if}} and {{#unless}} work for truthy/falsy checks.

Loops

<table>
  {{#each data.items}}
    <tr>
      <td>{{this.name}}</td>
      <td>{{this.quantity}}</td>
      <td>{{formatCurrency this.price}}</td>
    </tr>
  {{else}}
    <tr><td colspan="3">No items.</td></tr>
  {{/each}}
</table>

Inside a loop, {{this}} is the current item, {{@index}} is the 0-based position, and {{@first}} / {{@last}} are booleans for the first and last iteration.

Safety cap: arrays passed via data are capped at 50 items to prevent runaway templates. If you need more, paginate your list.

Helpers

HelperExampleOutput
default{{default first_name "there"}}Jane / there
uppercase{{uppercase first_name}}JANE
lowercase{{lowercase email}}jane@example.com
capitalize{{capitalize first_name}}Jane
titlecase{{titlecase "jane doe"}}Jane Doe
truncate{{truncate contact.company 20}}Acme Corporati…
formatDate{{formatDate data.date "MMM d, yyyy"}}Apr 17, 2026
formatCurrency{{formatCurrency data.total "USD"}}$1,234.00
formatNumber{{formatNumber data.count}}1,234
pluralize{{pluralize data.n "item" "items"}}3 items
add / subtract / multiply / divide{{multiply price qty}}numeric

Date formatting reference

We use date-fns tokens. Common patterns:

  • "yyyy-MM-dd" → 2026-04-17
  • "MMM d, yyyy" → Apr 17, 2026
  • "MMMM d, yyyy" → April 17, 2026
  • "EEEE, MMMM d" → Friday, April 17
  • "h:mm a" → 3:45 PM

Worked Example: Order Confirmation

A transactional send hitting POST /api/v1/sends with this template:

<h1>Order #{{data.order_number}}</h1>
<p>Thanks, {{first_name}}!</p>
<table>
  {{#each data.items}}
    <tr>
      <td>{{this.name}}</td>
      <td>{{this.quantity}} × {{formatCurrency this.price}}</td>
    </tr>
  {{/each}}
  <tr><td>Total</td><td>{{formatCurrency data.total}}</td></tr>
</table>
{{#if data.discount_code}}
  <p>Discount: {{data.discount_code}} ({{formatCurrency data.discount_amount}} off)</p>
{{/if}}
<p>Delivery: {{formatDate data.eta "MMMM d, yyyy"}}</p>

…and this personalizations payload:

{
  "order_number": "ORD-4821",
  "items": [
    { "name": "Wireless Earbuds", "quantity": 1, "price": 79.99 },
    { "name": "Phone Case", "quantity": 2, "price": 24.99 }
  ],
  "total": 129.97,
  "discount_code": "SAVE10",
  "discount_amount": 12.99,
  "eta": "2026-04-22"
}

Preview Before Sending

Use POST /api/v1/templates/:id/preview with a contactId and sample data to get the rendered HTML for that specific recipient. Great for debugging conditional and loop output before you hit send.

Gotchas

  • HTML is auto-escaped. If a contact's name contains < or &, it gets safely escaped. To inject raw HTML, use triple braces: {{{value}}}. Only do this for values you trust.
  • Syntax errors don't block sends. If a template has a render error, Pigeon logs it and falls back to the unrendered HTML so the email still gets delivered. Catch errors early by running POST /api/v1/templates/validate on save.
  • Custom helpers are not user-definable. For security, the helper list above is fixed. If you need a new one, open a feature request.