Building a Campaign Management System
A technical walkthrough of designing and implementing a campaign management system in TypeScript, covering campaign lifecycle, audience targeting, multi-channel delivery, and performance tracking.
A CRM without campaign management is a phone book — it stores contact information but does not do anything with it. Campaign management is the bridge between customer intelligence and customer communication. It takes the segments, scores, and predictions that a CRM produces and translates them into targeted messages delivered through the right channel at the right time.
At Klivvr, CVM Nova's campaign management system handles everything from simple one-time email blasts to complex multi-step journeys with conditional branching, A/B testing, and real-time triggering. This article covers the architecture, data model, and implementation patterns that make it work.
Campaign Data Model
The data model is the backbone of the campaign system. It must be flexible enough to represent diverse campaign types — one-time sends, recurring schedules, event-triggered flows — while remaining simple enough for business users to configure through a UI.
interface Campaign {
id: string;
name: string;
description: string;
type: "one-time" | "recurring" | "triggered" | "journey";
status: "draft" | "scheduled" | "active" | "paused" | "completed" | "archived";
audience: AudienceDefinition;
schedule?: CampaignSchedule;
trigger?: CampaignTrigger;
steps: CampaignStep[];
goals: CampaignGoal[];
createdBy: string;
createdAt: Date;
updatedAt: Date;
}
interface AudienceDefinition {
type: "segment" | "custom-query" | "csv-upload";
segmentIds?: string[];
customQuery?: string;
excludeSegmentIds?: string[];
estimatedSize?: number;
}
interface CampaignSchedule {
startDate: Date;
endDate?: Date;
recurrence?: {
frequency: "daily" | "weekly" | "monthly";
interval: number;
dayOfWeek?: number;
dayOfMonth?: number;
};
timezone: string;
sendWindow?: {
startHour: number;
endHour: number;
};
}
interface CampaignTrigger {
eventType: string;
conditions: TriggerCondition[];
cooldownMinutes: number; // Prevent re-triggering within this window
}
interface TriggerCondition {
field: string;
operator: string;
value: unknown;
}
interface CampaignStep {
id: string;
order: number;
type: "message" | "wait" | "condition" | "split";
channel?: "email" | "push" | "sms" | "in-app";
templateId?: string;
waitDuration?: number; // minutes
condition?: StepCondition;
splitConfig?: SplitConfig;
}
interface StepCondition {
field: string;
operator: string;
value: unknown;
trueBranch: string; // step ID
falseBranch: string; // step ID
}
interface SplitConfig {
type: "ab-test" | "random";
variants: Array<{
id: string;
percentage: number;
nextStepId: string;
}>;
}
interface CampaignGoal {
name: string;
eventType: string;
windowDays: number;
targetConversionRate?: number;
}Several design decisions are worth highlighting. First, the CampaignStep type supports four step types: message (send something), wait (pause before the next step), condition (branch based on customer state), and split (A/B testing). This small set of primitives composes into arbitrarily complex campaign flows. Second, the audience definition supports multiple sources — predefined segments, custom queries, or uploaded lists — with explicit exclusion rules. Exclusions are as important as inclusions; you do not want to send a win-back campaign to a customer who just renewed yesterday.
Third, the trigger definition includes a cooldown. Without it, a customer who triggers the same event multiple times in rapid succession would receive duplicate messages, which damages the brand and annoys the customer.
Campaign Execution Engine
The execution engine is responsible for taking a campaign definition and producing concrete message deliveries. It operates in two modes: batch mode for scheduled campaigns and streaming mode for triggered campaigns.
interface MessageDelivery {
id: string;
campaignId: string;
stepId: string;
customerId: string;
channel: string;
templateId: string;
personalizedContent: Record<string, string>;
status: "pending" | "sent" | "delivered" | "opened" | "clicked" | "bounced" | "failed";
scheduledAt: Date;
sentAt?: Date;
deliveredAt?: Date;
}
class CampaignExecutor {
private segmentService: SegmentService;
private templateEngine: TemplateEngine;
private deliveryService: DeliveryService;
constructor(
segmentService: SegmentService,
templateEngine: TemplateEngine,
deliveryService: DeliveryService
) {
this.segmentService = segmentService;
this.templateEngine = templateEngine;
this.deliveryService = deliveryService;
}
async executeBatch(campaign: Campaign): Promise<ExecutionResult> {
const audience = await this.resolveAudience(campaign.audience);
const results: MessageDelivery[] = [];
const batchSize = 1000;
for (let i = 0; i < audience.length; i += batchSize) {
const batch = audience.slice(i, i + batchSize);
const deliveries = await Promise.all(
batch.map((customerId) =>
this.executeForCustomer(campaign, customerId, campaign.steps[0])
)
);
results.push(...deliveries.filter(Boolean) as MessageDelivery[]);
}
return {
campaignId: campaign.id,
totalAudience: audience.length,
totalDelivered: results.filter((r) => r.status === "sent").length,
totalFailed: results.filter((r) => r.status === "failed").length,
executedAt: new Date(),
};
}
private async executeForCustomer(
campaign: Campaign,
customerId: string,
step: CampaignStep
): Promise<MessageDelivery | null> {
if (step.type === "message" && step.templateId && step.channel) {
const customerData = await this.getCustomerData(customerId);
const content = await this.templateEngine.render(
step.templateId,
customerData
);
const delivery: MessageDelivery = {
id: generateId(),
campaignId: campaign.id,
stepId: step.id,
customerId,
channel: step.channel,
templateId: step.templateId,
personalizedContent: content,
status: "pending",
scheduledAt: new Date(),
};
await this.deliveryService.send(delivery);
delivery.status = "sent";
delivery.sentAt = new Date();
return delivery;
}
return null;
}
private async resolveAudience(
audience: AudienceDefinition
): Promise<string[]> {
let customerIds: string[] = [];
if (audience.type === "segment" && audience.segmentIds) {
for (const segmentId of audience.segmentIds) {
const members = await this.segmentService.getMembers(segmentId);
customerIds.push(...members);
}
}
// Apply exclusions
if (audience.excludeSegmentIds) {
const excludeIds = new Set<string>();
for (const segmentId of audience.excludeSegmentIds) {
const members = await this.segmentService.getMembers(segmentId);
members.forEach((id) => excludeIds.add(id));
}
customerIds = customerIds.filter((id) => !excludeIds.has(id));
}
// Deduplicate
return [...new Set(customerIds)];
}
private async getCustomerData(
customerId: string
): Promise<Record<string, unknown>> {
return {};
}
}
interface ExecutionResult {
campaignId: string;
totalAudience: number;
totalDelivered: number;
totalFailed: number;
executedAt: Date;
}
interface SegmentService {
getMembers(segmentId: string): Promise<string[]>;
}
interface TemplateEngine {
render(
templateId: string,
data: Record<string, unknown>
): Promise<Record<string, string>>;
}
interface DeliveryService {
send(delivery: MessageDelivery): Promise<void>;
}
function generateId(): string {
return Math.random().toString(36).substring(2, 15);
}Batch execution processes the audience in chunks of 1,000 to avoid memory pressure and to provide natural checkpointing. If the execution fails partway through, it can resume from the last completed batch rather than re-sending to the entire audience.
Event-Triggered Campaigns
Triggered campaigns fire in response to customer events — a first purchase, a missed payment, a birthday. They operate on a fundamentally different timescale than batch campaigns: the response must happen within seconds, not hours.
class TriggeredCampaignProcessor {
private activeCampaigns: Campaign[] = [];
private cooldownTracker: Map<string, Date> = new Map();
async processEvent(event: CustomerEvent): Promise<void> {
const matchingCampaigns = this.activeCampaigns.filter(
(c) =>
c.type === "triggered" &&
c.status === "active" &&
c.trigger?.eventType === event.eventType
);
for (const campaign of matchingCampaigns) {
if (!campaign.trigger) continue;
// Check cooldown
const cooldownKey = `${campaign.id}:${event.customerId}`;
const lastTriggered = this.cooldownTracker.get(cooldownKey);
if (lastTriggered) {
const elapsedMinutes =
(Date.now() - lastTriggered.getTime()) / (1000 * 60);
if (elapsedMinutes < campaign.trigger.cooldownMinutes) {
continue;
}
}
// Evaluate trigger conditions
const conditionsMet = campaign.trigger.conditions.every((condition) =>
this.evaluateCondition(event, condition)
);
if (conditionsMet) {
this.cooldownTracker.set(cooldownKey, new Date());
await this.executeCampaignForCustomer(campaign, event.customerId);
}
}
}
private evaluateCondition(
event: CustomerEvent,
condition: TriggerCondition
): boolean {
const value = event.payload[condition.field];
switch (condition.operator) {
case "eq": return value === condition.value;
case "gt": return (value as number) > (condition.value as number);
case "lt": return (value as number) < (condition.value as number);
case "contains": return String(value).includes(String(condition.value));
default: return false;
}
}
private async executeCampaignForCustomer(
campaign: Campaign,
customerId: string
): Promise<void> {
// Start the customer on the campaign's step sequence
}
}
interface CustomerEvent {
customerId: string;
eventType: string;
payload: Record<string, unknown>;
timestamp: Date;
}The cooldown tracker is an in-memory map for simplicity, but in production it is backed by Redis with TTL-based expiration. This ensures that cooldowns survive service restarts and are consistent across multiple instances of the trigger processor.
Performance Tracking and Analytics
Campaign management without measurement is marketing theater. Every campaign in CVM Nova tracks delivery metrics, engagement metrics, and goal conversions.
interface CampaignPerformance {
campaignId: string;
period: { start: Date; end: Date };
delivery: {
totalSent: number;
totalDelivered: number;
totalBounced: number;
deliveryRate: number;
};
engagement: {
totalOpened: number;
totalClicked: number;
openRate: number;
clickRate: number;
clickToOpenRate: number;
};
conversions: {
goalName: string;
totalConverted: number;
conversionRate: number;
revenueAttributed: number;
}[];
abTestResults?: ABTestResult[];
}
interface ABTestResult {
variantId: string;
variantName: string;
sampleSize: number;
conversionRate: number;
confidence: number;
isWinner: boolean;
}
function computeCampaignPerformance(
deliveries: MessageDelivery[],
goals: CampaignGoal[],
goalEvents: Map<string, CustomerEvent[]>
): CampaignPerformance {
const sent = deliveries.filter((d) => d.status !== "failed");
const delivered = deliveries.filter(
(d) => d.status === "delivered" || d.status === "opened" || d.status === "clicked"
);
const opened = deliveries.filter(
(d) => d.status === "opened" || d.status === "clicked"
);
const clicked = deliveries.filter((d) => d.status === "clicked");
const conversions = goals.map((goal) => {
const events = goalEvents.get(goal.eventType) ?? [];
const deliveredCustomers = new Set(delivered.map((d) => d.customerId));
const convertedEvents = events.filter((e) =>
deliveredCustomers.has(e.customerId)
);
return {
goalName: goal.name,
totalConverted: convertedEvents.length,
conversionRate:
deliveredCustomers.size > 0
? convertedEvents.length / deliveredCustomers.size
: 0,
revenueAttributed: convertedEvents.reduce(
(sum, e) => sum + ((e.payload.amount as number) ?? 0),
0
),
};
});
return {
campaignId: deliveries[0]?.campaignId ?? "",
period: {
start: new Date(
Math.min(...deliveries.map((d) => d.scheduledAt.getTime()))
),
end: new Date(
Math.max(...deliveries.map((d) => (d.sentAt ?? d.scheduledAt).getTime()))
),
},
delivery: {
totalSent: sent.length,
totalDelivered: delivered.length,
totalBounced: deliveries.filter((d) => d.status === "bounced").length,
deliveryRate: sent.length > 0 ? delivered.length / sent.length : 0,
},
engagement: {
totalOpened: opened.length,
totalClicked: clicked.length,
openRate: delivered.length > 0 ? opened.length / delivered.length : 0,
clickRate: delivered.length > 0 ? clicked.length / delivered.length : 0,
clickToOpenRate: opened.length > 0 ? clicked.length / opened.length : 0,
},
conversions,
};
}Attribution is the hardest part of campaign analytics. When a customer receives a campaign email and then makes a purchase three days later, was the purchase caused by the email or would it have happened anyway? CVM Nova uses a configurable attribution window (default 7 days) and supports holdout groups — a random subset of the audience that is excluded from delivery — to measure incremental lift.
Frequency Capping and Fatigue Management
Sending too many messages is worse than sending none. Campaign fatigue drives unsubscribes, negative brand perception, and in regulated industries, compliance violations.
interface FrequencyCap {
channel: string;
maxMessages: number;
windowDays: number;
}
class FrequencyCapEnforcer {
private deliveryLog: Map<string, MessageDelivery[]> = new Map();
private caps: FrequencyCap[];
constructor(caps: FrequencyCap[]) {
this.caps = caps;
}
canSend(customerId: string, channel: string): boolean {
const key = `${customerId}:${channel}`;
const deliveries = this.deliveryLog.get(key) ?? [];
for (const cap of this.caps) {
if (cap.channel !== channel) continue;
const windowStart = new Date(
Date.now() - cap.windowDays * 24 * 60 * 60 * 1000
);
const recentCount = deliveries.filter(
(d) => d.sentAt && d.sentAt > windowStart
).length;
if (recentCount >= cap.maxMessages) return false;
}
return true;
}
}Frequency caps are enforced at the delivery layer, not the campaign layer. This means that even if three separate campaigns target the same customer on the same day, the customer will not receive more messages than the cap allows. The execution engine checks the cap before each delivery and silently skips capped customers, logging the skip for analytics.
Conclusion
A campaign management system is one of the most complex components of a CRM, touching segmentation, personalization, multi-channel delivery, analytics, and compliance. The architecture described here — a flexible data model, batch and streaming execution, performance tracking, and frequency capping — provides the foundation for campaigns that are effective without being intrusive.
The guiding principle in CVM Nova's campaign system is that every message must earn its place in the customer's inbox. If you cannot articulate why a specific customer should receive a specific message at a specific time, the campaign should not be sent. The system's job is to make targeted, timely, relevant communication the path of least resistance — and to make mass blasts require deliberate effort.
Related Articles
Real-Time Customer Profiles with Event Streaming
A technical guide to building real-time customer profile systems using event streaming in TypeScript, covering event-driven architecture, stream processing, profile materialization, and consistency guarantees.
Customer Engagement Metrics That Matter
A practical guide to defining, measuring, and acting on customer engagement metrics in CRM platforms, with a focus on metrics that drive retention and revenue in fintech.
Data-Driven CRM: Strategy and Implementation
A strategic guide to building and operating a data-driven CRM practice, covering organizational alignment, data governance, analytics maturity models, and practical implementation roadmaps.