Back to all articles
Klaviyo IntegrationSeptember 23, 20265 min read

How to Build Dynamic Templates for Klaviyo

Webhook Engineer · Stripe & Dunning Specialist

What is a dynamic template?

A dynamic template is an email that fills itself in from data your business already has — the amount a customer owes, the date their card gets charged, their name — instead of you writing a fresh email every time something happens. Your business sells something, so you naturally have an "amount." Customers are sometimes slow to pay, so you're also dealing with an "amount due." If you run a monthly subscription at $12, you're dealing with dates — trial end dates, renewal dates. And your customers have names, which you obviously can't type in one at a time for every send. A dynamic template takes all of that and produces one email that renders correctly for every recipient, without you touching it per person.

If you send the same kind of email to more than a handful of people, you need one. The alternative is either generic mass emails that convert badly, or someone on your team manually typing customer names into a spreadsheet mail merge at 11pm.

Did you know: the first mass commercial email was sent on May 3, 1978 — DEC marketing manager Gary Thuerk emailed 393 ARPANET users inviting them to a product demo. It wasn't personalized at all; every recipient got the identical message, addresses and all, crammed into the header. The actual "merge" idea behind today's dynamic templates predates email entirely — it came from 1970s word processors, where mail merge was built to personalize paper form letters before anyone had an inbox to put them in.

A badly built dynamic template is worse than no personalization at all. A customer who gets "Hi {{ first_name }}," with the literal brackets showing, or a payment reminder for "$0.00," trusts your billing system less than before that email arrived. That's not a formatting bug — it's a trust bug, and trust bugs cost you renewals.

This guide uses Klaviyo's syntax throughout (it's Django-templating under the hood), but the principles hold for any provider.

Money, dates, and identity

Nearly every transactional or lifecycle email a business sends is built from some combination of three data types: money, dates, and identity. Get these three right and most of your templates work.

Money

Say you run a $12/month subscription. Somewhere in your billing system, that $12 exists — but rarely as a clean number. Stripe stores amounts in cents (1200, not 12.00), because floating-point currency math is a known source of billing bugs. That's correct on Stripe's end. It becomes your problem the moment you try to put it in an email.

Your backend sends Klaviyo a custom event:

\\\`json

{

"customer_email": "jane@example.com",

"amount_due": 1200,

"amount_due_cents": 1200,

"currency": "usd"

}

\\\`

A common mistake is trying to fix the formatting inside the template:

\\\`html

<!-- Wrong -->

${{ event.amount_due_cents }}

\\\`

That renders as $1200, not $12.00. Template languages are bad at math, and Klaviyo's is no exception — there's no division filter you should be relying on for currency. The fix is doing the conversion before the data leaves your server:

\\\`javascript

const amountDue = amountCents / 100; // 12.00

await sendKlaviyoEvent({

email: customer.email,

eventName: 'Payment Reminder',

properties: {

amount_due: amountDue, // 12.00 — already in dollars

amount_due_cents: amountCents, // 1200 — kept for your own records/debugging

currency: 'usd',

},

});

\\\`

The template then stays simple and safe:

\\\`html

Your payment of ${{ event.amount_due|default:"0.00" }} is due.

\\\`

Format currency where you control the logic — your backend — not where you're limited to a templating language's filters. Klaviyo should receive a number that's already correct to two decimal places. Almost every "why does the email say $0.00" ticket traces back to someone dividing by 100 in the template instead of in the code.

Dates

Same principle, different failure mode. A trial-ending date or a monthly renewal date is almost certainly stored as a timestamp — Unix time or an ISO string. Klaviyo's date filter expects a Unix timestamp and formats it for you:

\\\`html

Your trial ends on {{ event.trial_ends_at|date:"F j, Y" }}.

\\\`

That works if trial_ends_at is genuinely a Unix timestamp, not an ISO string like "2026-10-15T00:00:00Z", which the filter won't parse the way you'd expect. A date field that either doesn't render, or renders as a raw epoch number, is the second most common source of broken-looking emails.

Format the human-readable string on your own server, where you can test it deterministically, and send both:

\\\`javascript

properties: {

renewal_date: renewalDate.toLocaleDateString('en-US', {

month: 'long',

day: 'numeric',

}), // "March 4"

renewal_date_unix: Math.floor(renewalDate.getTime() / 1000), // fallback, for filters if needed

}

\\\`

\\\`html

Your subscription renews on {{ event.renewal_date|default:"your next billing date" }}.

\\\`

A renewal reminder with a broken date field fails at the one job it had — telling the customer exactly when money leaves their account. Get that wrong and you generate support tickets and chargebacks instead of preventing them.

Identity and fallbacks

You cannot hand-wire "Hi Jane," for every customer, which is why you're using merge tags in the first place. But relying on a merge tag is a promise: if the data isn't there, the template still needs to produce a sentence a human would write.

\\\`html

<!-- Fragile -->

Hi {{ first_name }},

<!-- Correct -->

Hi {{ first_name|default:"there" }},

\\\`

Without that filter, a customer whose signup form never captured a first name — common with OAuth signups and older records — gets an email that starts with "Hi ," complete with the stray comma. Apply the same discipline to every merge tag, not just names: for each {{ event.something }} in the template, decide what a person sees if the value is missing, and add a |default:"..." clause before you ship it.

The property-naming issue that causes most "why isn't this showing" bugs

Klaviyo lets you reference custom event properties two ways. If the property name is a valid identifier — no spaces, no special characters — dot notation works directly:

\\\`html

{{ event.decline_message }}

\\\`

If the name has a space or a character that breaks that syntax (say, a property literally named "Plan Name"), the lookup filter is required instead:

\\\`html

{{ event|lookup:'Plan Name' }}

\\\`

A template written as {{ event.extra|lookup:'amount_due' }} — assuming a nested object called extra holds all the custom properties — breaks silently when the actual payload sends amount_due as a flat, top-level property. Nothing errors. The filter returns nothing, the |default value fills in, and every field styled that way quietly shows its fallback instead of real data. The email still sends. It just lies about the numbers.

Keep event properties flat, and name them like valid identifiers from the start (amount_due, not "Amount Due"). That gets you plain dot notation everywhere and removes an entire category of silent bugs — anyone reading the template later, including future you, can tell exactly what data it expects just by reading it.

Test with a synthetic event before a real customer does

The cheapest QA process for a dynamic template is firing a synthetic version of the real event — with deliberately incomplete data — through the same code path a real customer's data goes through. Missing name, missing date, a zero amount. Solid fallbacks mean the email still reads like something a person wrote. If they're not solid, a QA run catches it instead of a customer's inbox.

\\\`javascript

// A synthetic event with intentionally missing fields —

// exercises every |default fallback before a real customer does

await sendKlaviyoEvent({

email: 'internal-test@yourcompany.com',

eventName: 'Payment Reminder',

properties: {

amount_due: 12.00,

// first_name deliberately omitted

// renewal_date deliberately omitted

},

});

\\\`

If that email reads naturally with those gaps, the template is production-ready.

What this buys you

Correct currency formatting stops billing confusion and the support load that comes with it. Correctly parsed, human-formatted dates turn renewal reminders into something that actually prevents surprise charges — the entire point of sending them. Disciplined fallbacks mean a gap in your customer data degrades into "Hi there" instead of "Hi ,". Flat, well-named properties remove a class of bug that hides in production for weeks, quietly showing customers the wrong numbers, because nothing about it throws an error.

Dynamic templates are how a small team sends email at the volume a growing customer base demands, without that volume reading as automated. Most of what makes them feel personal instead of broken isn't the templating language — it's deciding, for every field, what happens when the data isn't perfect. Eventually, it won't be.


Mathew Ngatia

Hi, I'm Mathew — I build Stripe billing infrastructure, dunning systems, and payment integrations that keep SaaS revenue from leaking. If this kind of thing is useful to you, I write more of it over on Medium and post updates on LinkedIn.


R
Webhook Engineer – 8 yrs Stripe integrations & dunning
Is your subscription revenue leaking?

I help SaaS founders recover up to 50-60% of their “failed payment” churn through technical Stripe & Klaviyo optimization.

Project: Stripe Audit $1,800 · includes dunning review

Newsletter

Revenue insights delivered to your inbox

Get weekly articles on Stripe optimization, dunning strategies, integration guides, and revenue engineering practices. No spam, just signal.