Building a Notification Templating Engine
How to build a notification templating engine with dynamic content, internationalization, personalization, and template versioning — with patterns from Whisper.
Every notification in a banking app carries specific content — the transaction amount, the merchant name, the account balance. Hardcoding these messages is not scalable. A templating engine separates message structure from message content, enabling non-engineers to manage notification copy, supporting multiple languages, and ensuring consistency across channels.
This article covers the templating engine behind Whisper's notification system.
Template Definition
Templates are defined as structured objects with placeholders for dynamic content. Each template has an identifier, supported channels, and localized versions.
interface NotificationTemplate {
id: string;
name: string;
version: number;
channels: ChannelTemplate[];
variables: VariableDefinition[];
category: string;
createdAt: Date;
updatedAt: Date;
}
interface ChannelTemplate {
channel: "push" | "sms" | "email" | "in_app";
locales: Record<string, LocalizedContent>;
}
interface LocalizedContent {
title?: string;
body: string;
actionUrl?: string;
}
interface VariableDefinition {
name: string;
type: "string" | "number" | "currency" | "date" | "boolean";
required: boolean;
defaultValue?: string;
format?: string;
}
// Example template
const transactionAlertTemplate: NotificationTemplate = {
id: "txn_alert",
name: "Transaction Alert",
version: 3,
channels: [
{
channel: "push",
locales: {
en: {
title: "Transaction Alert",
body: "{{type}} of {{amount}} at {{merchant}}. Balance: {{balance}}.",
},
ar: {
title: "\u062A\u0646\u0628\u064A\u0647 \u0645\u0639\u0627\u0645\u0644\u0629",
body: "{{type}} \u0628\u0642\u064A\u0645\u0629 {{amount}} \u0641\u064A {{merchant}}. \u0627\u0644\u0631\u0635\u064A\u062F: {{balance}}.",
},
},
},
{
channel: "in_app",
locales: {
en: {
title: "{{type}} - {{merchant}}",
body: "You made a {{type}} of {{amount}} at {{merchant}} on {{date}}. Your current balance is {{balance}}.",
actionUrl: "/transactions/{{transactionId}}",
},
ar: {
title: "{{type}} - {{merchant}}",
body: "\u0644\u0642\u062F \u0642\u0645\u062A \u0628\u0640{{type}} \u0628\u0642\u064A\u0645\u0629 {{amount}} \u0641\u064A {{merchant}} \u0628\u062A\u0627\u0631\u064A\u062E {{date}}. \u0631\u0635\u064A\u062F\u0643 \u0627\u0644\u062D\u0627\u0644\u064A {{balance}}.",
actionUrl: "/transactions/{{transactionId}}",
},
},
},
],
variables: [
{ name: "type", type: "string", required: true },
{ name: "amount", type: "currency", required: true, format: "{{value}} {{currency}}" },
{ name: "merchant", type: "string", required: true },
{ name: "balance", type: "currency", required: true },
{ name: "date", type: "date", required: true, format: "DD MMM YYYY" },
{ name: "transactionId", type: "string", required: true },
],
category: "transaction",
createdAt: new Date("2025-01-15"),
updatedAt: new Date("2025-05-10"),
};Template Rendering
The rendering engine resolves variables, applies formatting, and selects the correct locale.
class TemplateRenderer {
private formatters: Map<string, Formatter> = new Map();
constructor() {
this.formatters.set("currency", new CurrencyFormatter());
this.formatters.set("date", new DateFormatter());
this.formatters.set("number", new NumberFormatter());
}
render(
template: NotificationTemplate,
channel: string,
locale: string,
variables: Record<string, unknown>
): RenderedNotification {
// Find the channel template
const channelTemplate = template.channels.find(
(c) => c.channel === channel
);
if (!channelTemplate) {
throw new Error(`No template for channel: ${channel}`);
}
// Select locale with fallback
const content =
channelTemplate.locales[locale] ??
channelTemplate.locales["en"];
if (!content) {
throw new Error(`No locale found for: ${locale}`);
}
// Validate required variables
for (const varDef of template.variables) {
if (varDef.required && !(varDef.name in variables)) {
throw new Error(`Missing required variable: ${varDef.name}`);
}
}
// Format and interpolate variables
const formattedVars = this.formatVariables(
template.variables,
variables,
locale
);
return {
title: content.title
? this.interpolate(content.title, formattedVars)
: undefined,
body: this.interpolate(content.body, formattedVars),
actionUrl: content.actionUrl
? this.interpolate(content.actionUrl, formattedVars)
: undefined,
};
}
private interpolate(
template: string,
variables: Record<string, string>
): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return variables[key] ?? match;
});
}
private formatVariables(
definitions: VariableDefinition[],
values: Record<string, unknown>,
locale: string
): Record<string, string> {
const formatted: Record<string, string> = {};
for (const def of definitions) {
const value = values[def.name] ?? def.defaultValue;
if (value === undefined) continue;
const formatter = this.formatters.get(def.type);
formatted[def.name] = formatter
? formatter.format(value, locale, def.format)
: String(value);
}
return formatted;
}
}
interface Formatter {
format(value: unknown, locale: string, pattern?: string): string;
}
class CurrencyFormatter implements Formatter {
format(value: unknown, locale: string): string {
const { amount, currency } = value as { amount: number; currency: string };
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(amount);
}
}
class DateFormatter implements Formatter {
format(value: unknown, locale: string): string {
const date = new Date(value as string);
return new Intl.DateTimeFormat(locale, {
day: "numeric",
month: "short",
year: "numeric",
}).format(date);
}
}
interface RenderedNotification {
title?: string;
body: string;
actionUrl?: string;
}Template Versioning
Templates evolve over time — copy is refined, new variables are added, formatting changes. Versioning ensures that in-flight notifications use the template version they were created with, and rollback is possible if a new version causes issues.
class TemplateStore {
private db: Database;
async getTemplate(
id: string,
version?: number
): Promise<NotificationTemplate | null> {
if (version) {
return this.getSpecificVersion(id, version);
}
return this.getLatestVersion(id);
}
private async getLatestVersion(
id: string
): Promise<NotificationTemplate | null> {
const result = await this.db.query(
`SELECT * FROM notification_templates
WHERE id = $1 AND is_active = true
ORDER BY version DESC LIMIT 1`,
[id]
);
return result.rows[0] ?? null;
}
private async getSpecificVersion(
id: string,
version: number
): Promise<NotificationTemplate | null> {
const result = await this.db.query(
`SELECT * FROM notification_templates
WHERE id = $1 AND version = $2`,
[id, version]
);
return result.rows[0] ?? null;
}
async createVersion(
template: NotificationTemplate
): Promise<NotificationTemplate> {
const newVersion = template.version + 1;
await this.db.query(
`INSERT INTO notification_templates (id, name, version, channels, variables, category, is_active)
VALUES ($1, $2, $3, $4, $5, $6, true)`,
[
template.id,
template.name,
newVersion,
JSON.stringify(template.channels),
JSON.stringify(template.variables),
template.category,
]
);
return { ...template, version: newVersion };
}
async rollback(id: string, toVersion: number): Promise<void> {
await this.db.query(
`UPDATE notification_templates SET is_active = false WHERE id = $1`,
[id]
);
await this.db.query(
`UPDATE notification_templates SET is_active = true WHERE id = $1 AND version = $2`,
[id, toVersion]
);
}
}Conclusion
A notification templating engine separates content from delivery logic, enabling non-engineers to manage notification copy, supporting multiple languages through locale-aware rendering, and maintaining consistency across push, SMS, email, and in-app channels. Whisper's templating engine uses structured variable definitions with type-aware formatting, locale fallback chains, and template versioning to deliver personalized, localized notifications to Klivvr's users across all channels and languages.
Related Articles
Omnichannel Messaging Strategy for Fintech
How to build a unified omnichannel messaging strategy across push, SMS, email, and in-app channels — covering channel selection, message consistency, and unified customer experience.
Real-Time Notifications in Banking
How real-time notifications transform the banking experience, covering transaction alerts, security notifications, compliance requirements, and the business impact of instant communication.
Scaling Notification Systems
Strategies for scaling notification systems to millions of users, covering horizontal scaling, fan-out patterns, rate limiting, and infrastructure design — with patterns from Whisper.