system-design70 min read

How to Design an E-Commerce Multichannel Platform like Omnisend — A Senior+ Guide | Ayodhyya

How to Design an E-Commerce Multichannel Platform like Omnisend — A Senior+ Guide

Building email, SMS, and push notification orchestration for 70K+ e-commerce brands at scale

Published: June 21, 2024 Reading Time: ~45 min Author: Ayodhyya Engineering

1. Introduction — Why Omnisend Matters

In the rapidly evolving world of e-commerce marketing automation, Omnisend has carved out a remarkable position. Serving over 70,000 e-commerce brands worldwide, Omnisend provides a unified platform for email marketing, SMS campaigns, and web push notifications — all tightly integrated with major e-commerce platforms like Shopify, WooCommerce, BigCommerce, and Magento. Starting at just $16 per month for its standard plan, Omnisend democratizes multichannel marketing automation that was once available only to enterprise-level companies with six-figure budgets.

The core value proposition is elegant in its simplicity: e-commerce merchants need to reach their customers across multiple channels — email for detailed product storytelling, SMS for time-sensitive promotions and transactional updates, and push notifications for re-engagement — but managing these channels in isolation leads to fragmented customer experiences, duplicate messaging, and wasted marketing spend. Omnisend solves this by providing a single orchestration layer that coordinates messages across all three channels, ensuring the right message reaches the right customer on the right channel at the right time.

Consider a typical customer journey in an e-commerce store. A shopper browses a collection of running shoes, adds a pair to their cart, but leaves without completing the purchase. Within an hour, Omnisend can trigger an abandoned cart email showcasing the exact product with a compelling subject line. If the email goes unopened after four hours, the system can escalate to an SMS with a time-limited discount code. If the customer still has not converted after twenty-four hours, a web push notification with social proof — "23 people bought this shoe today" — can be dispatched. This orchestrated, multichannel approach consistently delivers 3x higher conversion rates compared to single-channel strategies.

Under the hood, building such a platform is an extraordinarily complex engineering challenge. You must process real-time webhooks from e-commerce platforms handling millions of orders daily, render personalized email templates with dynamic product blocks, manage carrier-grade SMS delivery across dozens of countries with strict compliance requirements, handle web push notification delivery through browser-specific protocols, maintain a recommendation engine that processes billions of behavioral events, and provide real-time analytics dashboards that attribute revenue to specific campaigns across channels. All of this must run with sub-second latency for triggered automations and five-nines reliability for transactional messages.

This system design article will walk you through every layer of building an Omnisend-like platform from the ground up. We will cover the data models that represent stores, contacts, campaigns, and workflows. We will design the APIs that power campaign creation and channel delivery. We will architect the high-level system with detailed Mermaid diagrams showing every component. We will deep-dive into each subsystem — the e-commerce integration layer, the email rendering engine, the SMS compliance framework, the push notification infrastructure, the automation workflow engine, the product recommendation system, and the audience segmentation engine. We will tackle multi-region deployment, cost optimization, and revenue attribution. And we will wrap up with a complete C# implementation of the core multichannel orchestration system exceeding 300 lines of production-quality code.

Whether you are preparing for a senior or staff-level system design interview, architecting a marketing automation platform for your organization, or simply curious about how platforms like Omnisend operate at scale, this article provides the depth and breadth you need. The concepts, patterns, and trade-offs discussed here are directly applicable to any multichannel messaging platform — from customer engagement tools like Braze and Iterable to transactional messaging services like SendGrid and Twilio. Let us begin.

2. Functional & Non-Functional Requirements

Functional Requirements

The functional requirements for an Omnisend-like platform span four major domains: multichannel campaign management, e-commerce integration, audience intelligence, and analytics. Let us enumerate each requirement in detail.

Multichannel Campaign Management

The system must support the creation and management of campaigns across three primary channels — email, SMS, and web push notifications. Campaigns can be either one-time broadcasts (sending a holiday sale announcement to all subscribers) or automated workflows triggered by specific events (abandoned cart, welcome series, post-purchase follow-up). The campaign builder must provide a drag-and-drop interface for email templates with product blocks that dynamically populate from the merchant's catalog. SMS campaigns must support both promotional and transactional messages with character count optimization and MMS support for image messages. Push notifications must support rich media, action buttons, and deep linking to specific product pages.

E-Commerce Platform Integration

The platform must integrate bidirectionally with major e-commerce platforms. For Shopify, this means handling webhook events for order creation, order fulfillment, product updates, customer creation, and cart updates. For WooCommerce, it means REST API polling and webhook subscription for equivalent events. The system must maintain a synchronized product catalog with images, prices, inventory levels, and variants. Customer profiles must be enriched with purchase history, browsing behavior, and lifetime value metrics sourced from the e-commerce platform.

Audience Intelligence

The platform must provide sophisticated audience segmentation based on behavioral events (page views, product views, add to cart, purchases), demographic data (location, language, acquisition source), RFM metrics (recency, frequency, monetary value), and lifecycle stages (new subscriber, first-time buyer, repeat customer, at-risk, churned). Segments must update in real-time as new events arrive. The system must also provide product recommendation capabilities based on collaborative filtering, purchase history analysis, and trending products within specific customer segments.

Analytics and Reporting

The platform must track delivery metrics (sent, delivered, opened, clicked, bounced, complained) across all channels, conversion metrics (revenue per email, revenue per SMS, conversion rate by campaign type), and multichannel attribution (which channel combination drove the highest ROI). Real-time dashboards must show campaign performance within minutes of send, and historical analytics must support drill-down by time period, channel, campaign type, and segment.

Non-Functional Requirements

RequirementTargetRationale
Throughput10M+ emails/day, 500K+ SMS/day, 2M+ push/dayServes 70K merchants with average 5K contacts each
Latency (Triggered)< 500ms p99Abandoned cart emails must fire within minutes
Latency (Broadcast)< 30 minutes full deliveryHoliday sale campaigns must reach all subscribers quickly
Availability99.99% uptimeTransactional messages like order confirmations are business-critical
DurabilityNo message lossEvery triggered message must be persisted before delivery attempt
ComplianceCAN-SPAM, GDPR, TCPA, CCPALegal requirements across operating regions
ScalabilityLinear horizontal scalingPlatform must handle Black Friday 10x traffic spikes
Key Design Insight: The most critical non-functional requirement is durability with no message loss. When a merchant triggers an abandoned cart workflow, that message must be persisted to a durable store before any delivery attempt. A temporary failure in the email provider must not lose the message — it should be retried from the durable queue with exponential backoff.

3. Capacity Estimation & Back-of-Envelope

Before diving into architecture, let us establish concrete capacity numbers that will drive our infrastructure decisions. These estimates are based on publicly available data about Omnisend's scale and reasonable assumptions about e-commerce marketing patterns.

Merchant and Contact Base

With 70,000 active merchants and an average of 5,000 contacts per merchant, our total addressable contact base is approximately 350 million contacts. However, not all contacts are active across all channels. Assuming 60% have opted into email, 15% into SMS, and 20% into push notifications, our effective reach is 210 million email addresses, 52.5 million phone numbers, and 70 million push subscribers.

Daily Message Volume

ChannelDaily VolumeCalculation
Email (triggered)3M messages/day70K stores × 43 triggered emails/store/day
Email (campaign)7M messages/day70K stores × 100 campaign recipients/store/day
SMS (triggered)200K messages/day70K stores × 3 triggered SMS/store/day
SMS (campaign)300K messages/day70K stores × 4 campaign SMS/store/day
Push (triggered)500K messages/day70K stores × 7 triggered push/store/day
Push (campaign)1.5M messages/day70K stores × 21 campaign push/store/day
Total12.5M messages/day

API and Storage Estimates

MetricDaily VolumePeak (10x Black Friday)
Webhook events from e-commerce platforms50M events/day500M events/day
Campaign creation API calls100K/day1M/day
Analytics event ingestion200M events/day2B events/day
Product catalog sync operations10M/day100M/day
Storage for contact profiles350M × 2KB = 700GB700GB baseline
Storage for message logs (daily)12.5M × 500B = 6.25GB62.5GB
Storage for analytics events (daily)200M × 200B = 40GB400GB
Total database storage (annual)~15TB~15TB + growth

Bandwidth Estimates

Incoming webhook traffic from Shopify alone can generate 2,000 requests per second during peak periods. Outgoing email traffic peaks at approximately 150,000 messages per minute during major campaign blasts. SMS traffic peaks at 10,000 messages per minute. Push notification traffic peaks at 30,000 messages per minute. Total outbound bandwidth for message delivery averages 5 Gbps during peak periods, with email accounting for 80% of bandwidth due to HTML template payloads.

Capacity Planning Takeaway: The system must be designed for a 10x spike on Black Friday and Cyber Monday. This means all queues, databases, and processing pipelines must support at least 10x normal throughput without degradation. Horizontal auto-scaling with pre-warmed capacity pools is essential.

4. Core Data Model

The data model for an Omnisend-like platform revolves around seven core entities: Stores, Contacts, Campaigns, Workflows, Messages, Orders, and Products. Let us define each entity with its key attributes and relationships.

Entity Relationship Overview

erDiagram STORE ||--o{ CONTACT : has STORE ||--o{ CAMPAIGN : creates STORE ||--o{ WORKFLOW : defines STORE ||--o{ PRODUCT : catalogs CONTACT ||--o{ MESSAGE : receives CONTACT ||--o{ ORDER : places CAMPAIGN ||--o{ MESSAGE : generates WORKFLOW ||--o{ MESSAGE : triggers ORDER ||--o{ ORDER_ITEM : contains PRODUCT ||--o{ ORDER_ITEM : listed_in CONTACT ||--o{ SEGMENT : belongs_to CONTACT ||--o{ EVENT : generates STORE { uuid id PK string name string platform "shopify|woocommerce|bigcommerce" string platform_store_id string timezone jsonb settings timestamp created_at } CONTACT { uuid id PK uuid store_id FK string email string phone string first_name string last_name jsonb properties jsonb tags string lifecycle_stage decimal lifetime_value timestamp subscribed_at } CAMPAIGN { uuid id PK uuid store_id FK string name string type "broadcast|abandoned_cart|welcome|post_purchase" jsonb channels "email|sms|push" jsonb audience_config jsonb content string status "draft|scheduled|sending|sent" timestamp scheduled_at } MESSAGE { uuid id PK uuid campaign_id FK uuid workflow_id FK uuid contact_id FK string channel "email|sms|push" string status "queued|sent|delivered|opened|clicked|bounced|complained" jsonb content string provider_message_id decimal cost timestamp sent_at timestamp delivered_at timestamp opened_at timestamp clicked_at } WORKFLOW { uuid id PK uuid store_id FK string name string trigger_type "cart_abandoned|order_placed|subscriber_added" jsonb steps boolean is_active int enrollment_count timestamp created_at } PRODUCT { uuid id PK uuid store_id FK string external_id string title string description decimal price string image_url jsonb variants jsonb tags int inventory_count } ORDER { uuid id PK uuid contact_id FK uuid store_id FK string external_id decimal total_amount decimal discount_amount string currency string status "pending|paid|fulfilled|refunded" jsonb items timestamp placed_at }

Contact Properties Model

The Contact entity is the most complex in the system because it serves as the convergence point for all behavioral data, channel preferences, and segmentation logic. Each contact stores a flexible properties JSONB column that holds custom attributes from the e-commerce platform — first purchase date, total orders count, average order value, preferred product categories, loyalty tier, and any custom fields the merchant defines. This flexible schema avoids the need for ALTER TABLE operations when merchants add new custom properties.

The tags JSONB column stores both system-generated tags (like "active_email_subscriber", "sms_opted_in", "high_value_customer") and merchant-defined tags. Tags are indexed using GIN indexes in PostgreSQL for efficient filtering. The lifecycle_stage enum tracks the contact's progression through the marketing funnel: subscriber, first_time_buyer, repeat_buyer, loyal_customer, at_risk, and churned. This field is updated by the automation engine based on recency of engagement and purchase behavior.

Message State Machine

Every message follows a strict state machine that governs its lifecycle. The states are: Created, Queued, Sending, Sent, Delivered, Opened, Clicked, Bounced, Complained, and Failed. State transitions are recorded in an immutable audit log to support debugging and analytics. The provider_message_id field stores the ID returned by the downstream provider (SendGrid for email, Twilio for SMS, Firebase for push) and is used for status callback reconciliation.

Design Pattern: Use an event-sourced approach for message state transitions. Every state change emits an event that is appended to an immutable log. This provides a complete audit trail, enables point-in-time replay for debugging, and feeds the analytics pipeline without requiring separate ETL processes.

5. API Design

The platform exposes a RESTful API organized around four resource groups: Campaigns, Contacts, Messages, and Analytics. All endpoints require authentication via API key and support rate limiting per store. Let us define the critical endpoints.

Campaign Endpoints

HTTP
POST   /api/v1/stores/{storeId}/campaigns
GET    /api/v1/stores/{storeId}/campaigns/{campaignId}
PUT    /api/v1/stores/{storeId}/campaigns/{campaignId}
POST   /api/v1/stores/{storeId}/campaigns/{campaignId}/send
POST   /api/v1/stores/{storeId}/campaigns/{campaignId}/schedule
GET    /api/v1/stores/{storeId}/campaigns/{campaignId}/stats

Contact Endpoints

HTTP
POST   /api/v1/stores/{storeId}/contacts
POST   /api/v1/stores/{storeId}/contacts/bulk
GET    /api/v1/stores/{storeId}/contacts/{contactId}
PUT    /api/v1/stores/{storeId}/contacts/{contactId}
DELETE /api/v1/stores/{storeId}/contacts/{contactId}
GET    /api/v1/stores/{storeId}/contacts/{contactId}/events
GET    /api/v1/stores/{storeId}/contacts/{contactId}/orders

Channel-Specific Send Endpoints

HTTP
POST   /api/v1/stores/{storeId}/channels/email/send
POST   /api/v1/stores/{storeId}/channels/sms/send
POST   /api/v1/stores/{storeId}/channels/push/send

Webhook Endpoints (Inbound)

HTTP
POST   /webhooks/shopify/{storeId}
POST   /webhooks/woocommerce/{storeId}
POST   /webhooks/bigcommerce/{storeId}
POST   /webhooks/email/delivery-status
POST   /webhooks/sms/status-callback
POST   /webhooks/push/fcm-status

Example: Create Campaign Request

JSON
POST /api/v1/stores/a1b2c3d4/campaigns
{
  "name": "Summer Sale 2026",
  "type": "broadcast",
  "channels": {
    "email": {
      "enabled": true,
      "subject": "Summer Sale — Up to 50% Off!",
      "from_name": "My Store",
      "from_email": "sale@mystore.com",
      "template_id": "summer-sale-v2",
      "product_block_ids": ["featured", "bestsellers"]
    },
    "sms": {
      "enabled": true,
      "message": "SUMMER SALE: 50% off sitewide. Shop now: {{short_link}} Reply STOP to opt out",
      "mms_image_url": null
    },
    "push": {
      "enabled": true,
      "title": "Summer Sale is Live!",
      "body": "Up to 50% off all summer collections",
      "image_url": "https://cdn.mystore.com/sale-banner.png",
      "deep_link": "/collections/summer"
    }
  },
  "audience": {
    "segment_id": "active-subscribers-30d",
    "exclude_segment_id": "purchased-last-7d"
  },
  "schedule": {
    "send_at": "2026-07-15T10:00:00Z",
    "send_in_local_timezone": true
  },
  "settings": {
    "utm_source": "omnisend",
    "utm_medium": "email",
    "utm_campaign": "summer-sale-2026"
  }
}

Rate Limiting Strategy

Endpoint GroupRate LimitWindow
Campaign creation10 requestsper minute per store
Contact CRUD1,000 requestsper minute per store
Bulk contact import5 requestsper minute per store
Analytics queries100 requestsper minute per store
Webhook ingestion10,000 requestsper minute per store

6. High-Level Architecture

The Omnisend-like platform is composed of eight major subsystems that work together to deliver multichannel marketing automation. The architecture follows an event-driven microservices pattern where each subsystem communicates through a central message bus (Apache Kafka) for asynchronous processing and through gRPC for synchronous service-to-service calls.

graph TB subgraph "E-Commerce Platforms" SH[Shopify] WC[WooCommerce] BC[BigCommerce] end subgraph "Ingestion Layer" WH[Webhook Gateway] PS[Product Sync Service] end subgraph "Core Platform" CS[Contact Service] CMS[Campaign Service] WFS[Workflow Engine] SEG[Segmentation Engine] RECS[Recommendation Engine] end subgraph "Channel Delivery" EG[Email Gateway] SGS[SMS Gateway] PG[Push Gateway] end subgraph "Data Layer" PGDB[(PostgreSQL)] RD[(Redis Cache)] ES[(Elasticsearch)] KAFKA[Kafka Bus] end subgraph "Analytics" AE[Analytics Engine] DASH[Dashboard] ATTR[Attribution Engine] end SH --> WH WC --> WH BC --> WH WH --> KAFKA PS --> SH PS --> WC KAFKA --> CS KAFKA --> CMS KAFKA --> WFS KAFKA --> SEG KAFKA --> AE CS --> PGDB CMS --> PGDB WFS --> PGDB SEG --> RD RECS --> RD CMS --> EG CMS --> SGS CMS --> PG WFS --> EG WFS --> SGS WFS --> PG EG --> RD SGS --> RD PG --> RD AE --> DASH AE --> ATTR EG --> KAFKA SGS --> KAFKA PG --> KAFKA KAFKA --> AE

Component Responsibilities

Webhook Gateway is the entry point for all e-commerce platform events. It validates incoming webhook signatures (Shopify HMAC, WooCommerce webhook secrets), normalizes events into a canonical schema, deduplicates retries, and publishes events to Kafka. The gateway must handle 50M+ events per day during peak periods and must never lose an event — it writes raw payloads to an S3 dead-letter bucket before processing as a safety net.

Contact Service manages the lifecycle of customer profiles. It merges data from multiple sources (e-commerce platform webhooks, manual CSV imports, API submissions) into a unified profile using deterministic identity resolution based on email address and phone number. It maintains channel subscription preferences, opt-in/opt-out status, and custom properties. The contact service exposes both synchronous APIs for real-time lookups and asynchronous event publishing for downstream consumers like the segmentation engine.

Campaign Service handles campaign CRUD operations, audience targeting, scheduling, and send orchestration. When a campaign is triggered for send, the campaign service resolves the target audience (either a static segment snapshot or a dynamically evaluated segment), generates per-contact message payloads with personalized content, and publishes send jobs to the channel-specific Kafka topics. The campaign service also manages A/B testing logic, send-time optimization, and throttle controls.

Workflow Engine is the automation backbone. It evaluates trigger events (cart abandoned, order placed, product viewed), determines which contacts qualify for which workflows, manages workflow step sequences with time delays, and coordinates multi-step, multi-channel journeys. The workflow engine must handle millions of concurrent workflow enrollments and must guarantee exactly-once execution semantics to prevent duplicate sends.

Segmentation Engine evaluates segment rules against the contact database in real-time. When a contact's properties or behavioral events change, the segmentation engine re-evaluates which segments the contact belongs to and publishes membership change events. Segments are cached in Redis for fast lookups during campaign send time.

Channel Gateways (Email, SMS, Push) are the delivery adapters that interface with downstream providers. The Email Gateway integrates with SendGrid, Amazon SES, or a custom SMTP cluster. The SMS Gateway integrates with Twilio, MessageBird, or regional carriers. The Push Gateway integrates with Firebase Cloud Messaging and web push services. Each gateway handles provider-specific formatting, rate limiting, retry logic, and status callback processing.

7. E-Commerce Platform Integration

The e-commerce integration layer is the foundation upon which the entire platform operates. Without real-time, reliable data from Shopify, WooCommerce, and other platforms, the system cannot trigger automations, personalize campaigns, or track revenue attribution. This layer must handle three distinct data flows: inbound webhooks (real-time event streams), outbound API calls (product sync, order status), and bulk data imports (initial store connection, periodic full syncs).

Shopify Webhook Processing

Shopify provides webhooks for over 50 event types. For an Omnisend-like platform, the critical webhooks are: orders/create, orders/fulfilled, orders/paid, orders/updated, products/create, products/update, products/delete, customers/create, customers/update, carts/update, and app/uninstalled. Each webhook is delivered as an HTTP POST with a JSON payload and an HMAC-SHA256 signature in the X-Shopify-Hmac-Sha256 header for authenticity verification.

sequenceDiagram participant SH as Shopify participant GW as Webhook Gateway participant K as Kafka participant CS as Contact Service participant WFS as Workflow Engine participant PS as Product Sync SH->>GW: POST /webhooks/shopify/order/create GW->>GW: Verify HMAC signature GW->>GW: Deduplicate (idempotency key) GW->>K: Publish normalized event K->>CS: Customer event → upsert profile K->>WFS: Order event → evaluate workflows K->>PS: Product event → sync catalog Note over GW: Raw payload saved to S3 for replay

WooCommerce Integration

WooCommerce does not provide the same robust webhook infrastructure as Shopify. The platform must use a hybrid approach: WordPress webhooks for real-time events (orders, products) combined with REST API polling for supplementary data (customer meta, coupon usage). The polling interval is adaptive — high-activity stores are polled every 30 seconds, while dormant stores are polled every 5 minutes. This adaptive polling strategy reduces API load on WooCommerce sites while ensuring timely data synchronization.

Product Catalog Synchronization

Product catalog synchronization is one of the most data-intensive operations in the system. A typical Shopify store may have 1,000 to 50,000 products, each with multiple variants, images, and metadata. The initial sync for a large store can transfer hundreds of megabytes of product data. The synchronization service uses cursor-based pagination to iterate through the product catalog, compares each product against the local cache, and issues upsert operations for changed products. Products are synced every 15 minutes for active stores and every hour for inactive stores.

Data TypeSync FrequencyVolume per StoreMethod
ProductsEvery 15 minutes1K-50K productsShopify REST/GraphQL API
CustomersReal-time webhook + hourly full sync10K-500K customersWebhook + REST API
OrdersReal-time webhook100-10K orders/dayWebhook
CartsReal-time webhook500-50K carts/dayWebhook
InventoryEvery 30 minutes1K-50K variantsGraphQL API

Error Handling and Retry Strategy

E-commerce webhooks can fail for various reasons: the merchant's server is down, the webhook endpoint times out, or the payload exceeds size limits. The webhook gateway implements a three-tier retry strategy: immediate retry (3 attempts with 1-second delay), delayed retry (3 attempts with exponential backoff from 30 seconds to 5 minutes), and dead-letter queue (for events that fail all retries). Dead-letter events are flagged for manual review and are accessible through an admin dashboard for reprocessing.

Production Lesson: Shopify webhook delivery is not guaranteed — it retries for up to 48 hours but can drop events during prolonged outages. Always implement periodic full-sync reconciliation jobs that compare the local product and customer database against the source platform to catch any missed webhooks. Run these reconciliation jobs every 6 hours for products and daily for customers.

8. Multichannel Campaign Builder

The multichannel campaign builder is the crown jewel of the platform's user experience. It enables merchants to create coordinated campaigns that span email, SMS, and push notifications within a single campaign entity. The builder must handle the unique constraints and capabilities of each channel while presenting a unified interface.

Campaign Architecture

graph LR subgraph "Campaign Builder UI" EDITOR[Visual Editor] PREVIEW[Preview Panel] AUDIENCE[Audience Selector] SCHEDULER[Schedule Config] end subgraph "Campaign Service" VALID[Validator] RESOLVER[Audience Resolver] SPLIT[Channel Splitter] PERSONAL[Personalization Engine] end subgraph "Channel Queues" EQ[Email Queue] SQ[SMS Queue] PQ[Push Queue] end EDITOR --> VALID AUDIENCE --> RESOLVER SCHEDULER --> VALID VALID --> SPLIT RESOLVER --> SPLIT SPLIT --> PERSONAL PERSONAL --> EQ PERSONAL --> SQ PERSONAL --> PQ

Campaign Send Flow

When a merchant clicks "Send Now" or when a scheduled campaign reaches its send time, the following sequence executes: First, the audience resolver evaluates the target segment and produces a snapshot of qualifying contact IDs. This snapshot is immutable — contacts who join or leave the segment after this point do not affect the campaign. Second, the channel splitter determines which channels each contact is eligible for based on their subscription preferences. A contact opted into email but not SMS will only receive the email variant. Third, the personalization engine generates per-contact message content by merging template variables (first name, last_name, cart contents, recommended products) with the campaign content.

Fourth, the system applies suppression rules: contacts who received a message from the same store within the configured suppression window (typically 24 hours for email, 48 hours for SMS) are excluded. Fifth, the system applies throttling rules to prevent overwhelming downstream providers. Email sends are throttled to 50,000 messages per minute per store. SMS sends are throttled to 500 messages per minute per store. Push sends are throttled to 5,000 messages per minute per store. Sixth, messages are published to the channel-specific Kafka topics for asynchronous delivery.

Campaign Scheduling Intelligence

The campaign scheduler supports three modes: immediate send, fixed time, and optimized send-time. The optimized send-time mode uses historical open and click data for each contact to determine the optimal delivery window. The system analyzes the last 90 days of engagement data for each contact, clusters delivery times into hourly buckets, and selects the hour with the highest historical engagement rate. For contacts with insufficient history, the system falls back to the store-level optimal send time calculated from aggregate data.

Scheduling ModeData RequiredImprovement Over Fixed
ImmediateNoneBaseline
Fixed TimeMerchant preference+15% open rate
Optimized Send-Time90-day engagement history per contact+25-35% open rate
AI-Predicted OptimalML model with 180-day data+40-50% open rate

Content Personalization Variables

The personalization engine supports a rich set of template variables that merchants can embed in their campaign content. These include contact properties (first_name, last_name, email, custom fields), cart properties (cart_total, cart_item_count, cart_items with nested product data), order properties (last_order_total, total_orders, total_spent), product recommendations (recommended_products as an array of up to 10 products), and store properties (store_name, support_email, unsubscribe_url). Template variables use the double-brace syntax: {{contact.first_name}}, {{cart.items[0].title}}, {{recommendations[0].image_url}}.

9. Email Campaign Engine

The email campaign engine is the most mature and feature-rich component of the platform, reflecting email's position as the highest-ROI channel in e-commerce marketing. The engine handles template rendering, dynamic content insertion, deliverability management, and engagement tracking.

Email Rendering Pipeline

Email rendering is a multi-stage pipeline that transforms a merchant's campaign configuration into a fully rendered, personalized HTML email ready for delivery. The pipeline stages are: Template Selection (merchant chooses from pre-built or custom templates), Block Assembly (product blocks, image blocks, text blocks, discount code blocks are assembled in layout order), Variable Resolution (personalization tokens are replaced with contact-specific values), Product Block Population (recommended or specified products are fetched from the product cache and rendered), HTML Inlining (CSS classes are inlined for email client compatibility), Preview Generation (desktop and mobile previews are rendered for merchant review), and Final Encoding (the HTML is UTF-8 encoded and wrapped in the MIME message structure).

graph TB TPL[Template Store] --> ASM[Block Assembly] ASM --> VR[Variable Resolution] VR --> PB[Product Block Population] PB --> CI[CSS Inlining] CI --> PG[Preview Generation] PG --> FE[Final MIME Encoding] FE --> QUEUE[Send Queue] PB --> PC[Product Cache] VR --> CC[Contact Cache]

Dynamic Product Blocks

Product blocks are the most powerful feature in Omnisend-style email campaigns. A product block automatically populates with products from the merchant's catalog based on configurable rules. The supported product block types are: Bestsellers (top N products by sales volume in a specified time range), Recently Viewed (products the specific contact has viewed, sourced from behavioral events), Recommended (personalized recommendations from the recommendation engine), Specific Products (merchant manually selects products), Category Products (products from a specific collection or category), and Abandoned Cart Products (products from the contact's abandoned cart). Each product block renders the product image, title, price (with sale price comparison), and a deep link to the product page. The rendering engine must handle edge cases like out-of-stock products (show a "sold out" badge), products with missing images (show a placeholder), and products with multiple variants (show the default variant).

Deliverability Management

Email deliverability is the single most critical factor in email marketing success. The engine must manage sender reputation, authentication protocols, and complaint handling to ensure emails reach the inbox rather than the spam folder. Authentication is managed through DKIM signing (the platform generates and publishes DKIM keys for each sending domain), SPF alignment (the platform's sending IPs are included in the merchant's SPF record), and DMARC policy enforcement (the platform monitors DMARC aggregate reports and alerts merchants about alignment failures).

Deliverability FactorTarget MetricRemediation Action
Bounce Rate< 2%Auto-remove hard bounces, suppress soft bounces after 3 consecutive
Complaint Rate< 0.1%Auto-suppress complainers, alert merchant, review content
Open Rate> 15%Alert if below threshold, suggest subject line optimization
Inbox Placement Rate> 90%Monitor via seed list testing, adjust sending patterns
Spam Trap Hit Rate0%Regular list hygiene, engagement-based suppression

Transactional vs. Promotional Email

The platform must clearly distinguish between transactional emails (order confirmations, shipping notifications, password resets) and promotional emails (campaigns, newsletters, abandoned cart reminders). Transactional emails are sent through a dedicated IP pool with separate reputation management, are not subject to frequency capping, and must never be suppressed. Promotional emails are sent through the shared or merchant-dedicated IP pool, are subject to frequency capping and suppression rules, and are optimized for send-time delivery. Mixing transactional and promotional email on the same IP pool risks damaging sender reputation if promotional engagement is poor.

Deliverability Pro Tip: Implement a pre-send reputation check that evaluates the target audience quality before sending a campaign. If the bounce rate prediction exceeds 3% based on historical data for the target segment, block the campaign and alert the merchant to clean their list. This proactive approach prevents reputation damage before it occurs.

10. SMS Marketing System

SMS marketing is the fastest-growing channel in e-commerce, offering open rates above 95% and click-through rates 3-5x higher than email. However, SMS is also the most regulated channel, with strict compliance requirements that vary by country. The platform must handle opt-in management, consent tracking, message formatting, carrier-grade delivery, and two-way messaging.

Compliance Framework

SMS compliance is non-negotiable. The platform must enforce TCPA regulations in the United States, PECR in the United Kingdom, CASL in Canada, and GDPR in the European Union. Key compliance requirements include: explicit written consent before sending promotional SMS (the consent record must be stored with timestamp, IP address, and consent language), clear opt-out instructions in every promotional message (STOP to unsubscribe), immediate processing of opt-out requests (within 10 seconds), maintenance of a do-not-call list that is checked before every send, and geographic restrictions (certain message types are prohibited in specific jurisdictions).

C#
public class SmsComplianceService
{
    private readonly IConsentRepository _consentRepo;
    private readonly IDoNotCallRepository _dncRepo;
    private readonly IGeoRestrictionService _geoService;

    public async Task<ComplianceResult> CheckComplianceAsync(
        string phoneNumber, string messageType, string countryCode)
    {
        // Check 1: Is the number on the DNC list?
        if (await _dncRepo.IsBlockedAsync(phoneNumber))
            return ComplianceResult.Failed("Number is on Do-Not-Call list");

        // Check 2: Do we have valid consent for this message type?
        var consent = await _consentRepo.GetConsentAsync(phoneNumber);
        if (consent == null || !consent.IsValidFor(messageType))
            return ComplianceResult.Failed("No valid consent for message type");

        // Check 3: Geographic restrictions
        if (await _geoService.IsRestrictedAsync(countryCode, messageType))
            return ComplianceResult.Failed("Message type restricted in this region");

        // Check 4: Frequency limits
        var recentCount = await _consentRepo.GetMessageCountLast24hAsync(phoneNumber);
        if (recentCount >= GetFrequencyLimit(countryCode))
            return ComplianceResult.Failed("Daily frequency limit exceeded");

        return ComplianceResult.Passed();
    }
}

SMS Message Formatting

SMS messages are constrained to 160 characters for standard GSM encoding or 70 characters for Unicode. The platform must intelligently handle message composition: short messages under 160 characters are sent as a single SMS, messages between 161 and 306 characters are concatenated into two SMS segments (with a per-segment cost increase), and messages exceeding 306 characters are truncated with a deep link to the full content. MMS messages support images, GIFs, and audio up to 500KB and are priced 3-5x higher than standard SMS, so the platform must provide clear cost estimates before sending.

Two-Way SMS

The platform supports two-way SMS for customer service scenarios. When a customer replies to a promotional message with a keyword (HELP, INFO, START, STOP), the system processes the keyword and either provides automated responses (HELP returns store support information, STOP triggers opt-out) or routes the conversation to the merchant's support inbox. The two-way messaging system uses a state machine that tracks conversation state: Initial, Opted-In, Pending Opt-Out, Opted-Out, and Support Escalation.

KeywordActionResponse
STOPImmediate opt-out"You have been unsubscribed. Reply START to re-subscribe."
STARTRe-subscribe"You have been re-subscribed. Reply STOP to unsubscribe."
HELPSupport info"For help, visit {support_url} or call {support_phone}."
INFOCampaign detailsReturns link to full campaign landing page
UNSUBSCRIBEAlternative opt-outSame as STOP

11. Push Notification System

Web push and mobile push notifications provide a free, high-engagement channel that complements email and SMS. Unlike email and SMS, push notifications do not require personal identifiers (email or phone number) — they use browser or device tokens, making them a lower-friction opt-in channel. The platform must support both web push (via the Push API and Service Workers) and mobile push (via Firebase Cloud Messaging for Android and APNs for iOS).

Web Push Architecture

Web push notifications are delivered through the browser's push service. The flow is: the merchant installs a JavaScript snippet on their storefront that registers a Service Worker and subscribes to the browser's push service. The subscription object contains the browser's push endpoint URL, the p256dh key for encryption, and the auth secret. When a push notification is sent, the platform encrypts the payload using the VAPID protocol and sends it to the browser's push service endpoint. The push service holds the notification until the browser is active, at which point it delivers it to the Service Worker for display.

sequenceDiagram participant B as Browser participant PW as Push Worker participant FCM as Firebase/FCM participant K as Kafka participant DASH as Analytics B->>PW: Subscribe to push PW->>FCM: Register token FCM-->>PW: Token stored PW->>PW: POST /api/v1/stores/{id}/push/subscribe Note over DASH: Campaign triggered DASH->>K: Send push notification K->>PW: Enqueue push messages PW->>PW: Encrypt payload (VAPID) PW->>FCM: Send to FCM FCM->>B: Deliver notification B->>PW: Track delivery + click PW->>K: Emit engagement event

Push Notification Best Practices

Push notifications have unique constraints that require careful handling. First, notifications must be short — browser notification banners truncate after approximately 40-50 characters for the title and 100-120 characters for the body. Second, rich media (images) must be pre-loaded and accessible via HTTPS URL, as not all browsers support inline images. Third, action buttons should be limited to 2-3 options to avoid clutter on mobile devices. Fourth, notifications must respect the user's Do Not Disturb settings — sending a push at 3 AM destroys engagement trust. The platform should use the contact's timezone and configured quiet hours to delay delivery.

Push Segmentation and Targeting

Push notifications benefit enormously from precise targeting because the cost per message is essentially zero but the cost of annoying users into unsubscribing is very high. The platform supports targeting push notifications based on: subscription recency (target users who subscribed in the last 30 days for highest engagement), browser type (Chrome, Firefox, Edge, Safari each have different capabilities), device type (mobile vs. desktop for different notification styles), geographic location (for region-specific promotions), and behavioral segments (users who added to cart but did not purchase). The platform automatically suppresses push notifications for users who have not engaged with any notification in the last 60 days, as these dormant subscribers are more likely to unsubscribe than to convert.

12. Workflow Automation Engine

The workflow automation engine is the most complex subsystem in the platform. It enables merchants to create multi-step, multi-channel automated workflows triggered by specific e-commerce events. Pre-built workflow templates for common scenarios like abandoned cart recovery, welcome series, and post-purchase follow-up account for the majority of messages sent through the platform.

Workflow Execution Model

graph TB TRIGGER[Event Trigger] --> ENROLL{Enrollment Check} ENROLL -->|Eligible| WAIT[Wait Step] ENROLL -->|Not Eligible| SKIP[Skip] WAIT -->|Delay elapsed| STEP{Step Type?} STEP -->|Email| EMAIL[Send Email] STEP -->|SMS| SMS[Send SMS] STEP -->|Push| PUSH[Send Push] STEP -->|Delay| WAIT2[Wait Step] STEP -->|Condition| CONDITION{Evaluate Condition} STEP -->|Goal| GOAL{Goal Met?} EMAIL --> NEXT[Next Step] SMS --> NEXT PUSH --> NEXT WAIT2 --> STEP CONDITION -->|True| EMAIL CONDITION -->|True| SMS CONDITION -->|True| PUSH CONDITION -->|False| EMAIL GOAL -->|Yes| EXIT[Exit Workflow] GOAL -->|No| NEXT NEXT --> STEP NEXT --> ENDWF[Workflow Complete]

Pre-Built Workflow Templates

The platform ships with battle-tested workflow templates that merchants can activate with a single click. The abandoned cart recovery workflow is the highest-value template, typically recovering 5-15% of abandoned carts. The workflow triggers when a cart update event is received with a total value exceeding a configurable threshold (default: $10). Step 1: Wait 1 hour, then send an email reminding the customer about their cart with product images and a direct checkout link. Step 2: If no purchase within 4 hours, send an SMS with a 10% discount code. Step 3: If no purchase within 24 hours, send a web push notification with social proof ("Others who viewed this product also purchased"). Step 4: If still no purchase after 48 hours, send a final email with a stronger incentive (15% off or free shipping). Goal: Exit workflow when order/create event is received for this contact.

Workflow TemplateTrigger EventAvg. Conversion RateAvg. Revenue per Enrollment
Abandoned Cartcart/updated (items present)8-12%$8.50
Welcome Seriescustomer/created3-5%$4.20
Post-Purchaseorder/fulfilled15-25% (repeat rate)$12.00
Browse Abandonmentproduct/viewed (no cart add)2-4%$3.80
Win-BackNo purchase in 90 days5-8%$6.50
Order Confirmationorder/paidN/A (transactional)N/A

Workflow State Management

Each workflow maintains state for every enrolled contact. The state includes: current step index, step-specific state (e.g., which variant of an A/B test was selected), timestamps for each completed step, and the overall enrollment status (active, completed, exited, paused). Workflow state is stored in a dedicated PostgreSQL table with row-level locking to prevent concurrent execution of the same workflow for the same contact. When a workflow step involves a time delay, the contact's state is persisted with a resume_at timestamp, and a scheduled job wakes the contact when the delay expires. This approach is more reliable than in-memory timers because it survives process restarts and server failures.

Exactly-Once Semantics: The workflow engine must guarantee that each step executes exactly once for each enrolled contact. This is challenging in a distributed system where network partitions and process crashes are common. The solution is a combination of idempotency keys (each step execution is identified by a unique key stored in the database) and a distributed lock (Redis-based) that prevents concurrent execution of the same step for the same contact. If a process crashes mid-execution, the lock expires after 30 seconds and another process picks up the step, but the idempotency key ensures the message is not sent twice.

13. Product Recommendation Engine

Product recommendations drive a significant portion of revenue for e-commerce merchants using Omnisend-style platforms. Recommendations appear in email product blocks, SMS messages with product suggestions, and push notifications highlighting trending products. The recommendation engine must generate personalized, relevant product suggestions for millions of contacts across thousands of stores in real-time.

Recommendation Algorithms

The engine employs three complementary algorithms that are blended based on available data. Collaborative filtering analyzes patterns across users — if users A and B both purchased products X and Y, and user A also purchased product Z, then user B is likely interested in product Z. Content-based filtering analyzes product attributes — if a user purchased running shoes, recommend other products in the running category with similar price points and brand affinity. Trending products analysis identifies products with accelerating sales velocity within specific categories, time windows, and geographic regions.

graph TB subgraph "Data Sources" BH[Behavioral Events] PH[Purchase History] PC[Product Catalog] TREND[Trending Data] end subgraph "Algorithms" CF[Collaborative Filtering] CBF[Content-Based Filtering] TREND_A[Trending Analysis] BLEND[Weighted Blending] end subgraph "Output" REC[Recommendation Cache] EMAIL_REC[Email Product Blocks] SMS_REC[SMS Suggestions] PUSH_REC[Push Suggestions] end BH --> CF PH --> CF PH --> CBF PC --> CBF TREND --> TREND_A CF --> BLEND CBF --> BLEND TREND_A --> BLEND BLEND --> REC REC --> EMAIL_REC REC --> SMS_REC REC --> PUSH_REC

Recommendation Context Types

The engine supports six distinct recommendation contexts, each optimized for a specific use case. Cart-based recommendations suggest complementary products based on current cart contents (if the cart contains a camera, recommend lenses and a camera bag). Purchase-based recommendations suggest products similar to previously purchased items for repeat purchase opportunities (consumables like skincare products). Browse-based recommendations use recent product view history to suggest similar items within the same category. Session-based recommendations use the current browsing session to provide real-time suggestions (if the user is viewing summer dresses, recommend more summer dresses). Popularity-based recommendations surface the overall bestsellers for the store, useful for new contacts with no history. Inventory-based recommendations prioritize products with high inventory levels that need clearance, useful for seasonal campaigns.

Recommendation Performance

MetricTargetMeasurement
Recommendation latency< 50ms p99Time from request to response
Cache hit rate> 85%Percentage of requests served from cache
Click-through rate> 3.5%Clicks on recommended products / impressions
Conversion rate> 1.2%Purchases of recommended products / clicks
Revenue attribution15-25% of totalRevenue from recommended product purchases

Real-Time Recommendation Updates

Recommendation results are cached in Redis with a TTL of 15 minutes for behavioral contexts (browse, cart, session) and 6 hours for purchase-based and popularity contexts. When a behavioral event arrives (product viewed, item added to cart), the engine can optionally trigger an immediate recommendation refresh for that specific contact, ensuring that the next email or push notification contains the most current suggestions. This event-driven refresh is throttled to one refresh per contact per 5 minutes to prevent excessive computation during rapid browsing sessions.

14. Audience Segmentation

Audience segmentation is the process of dividing a store's contact base into targeted groups based on shared characteristics, behaviors, or conditions. Segmentation is fundamental to effective marketing — sending the same message to all contacts produces mediocre results, while sending targeted messages to well-defined segments dramatically improves engagement and conversion rates. The platform provides a powerful segmentation engine that supports complex, multi-condition rules with real-time evaluation.

Segment Types

The platform supports four segment types. Static segments are point-in-time snapshots — a merchant creates a segment of "all customers who purchased in the last 30 days" and the segment is evaluated once at creation time. Dynamic segments continuously re-evaluate as new data arrives — the same "purchased in last 30 days" segment automatically removes contacts whose 30-day window has expired and adds new purchasers. Predictive segments use machine learning models to classify contacts into categories like "likely to purchase in the next 7 days" or "at risk of churning." Calculated segments use custom formulas to derive metrics like "customer lifetime value > $500" or "average order value > $100."

Segment Rule Engine

The segment rule engine supports a rich set of operators for building complex conditions. Behavioral rules include: has performed event (with constraints on event count, time window, and event properties), has not performed event, performed sequence (event A followed by event B within a time window), and performed funnel (percentage of users completing each step). Property rules include: equals, not equals, contains, starts with, greater than, less than, between, in list, and regex match. Temporal rules include: subscribed within the last N days, last active more than N days ago, and has been in segment for more than N days.

JSON
{
  "segment_name": "High-Value At-Risk Customers",
  "conditions": {
    "operator": "AND",
    "rules": [
      {
        "field": "contact.lifetime_value",
        "operator": "greater_than",
        "value": 500
      },
      {
        "field": "event.purchase",
        "operator": "has_not_performed",
        "constraints": {
          "within_days": 60
        }
      },
      {
        "field": "event.email_open",
        "operator": "has_not_performed",
        "constraints": {
          "within_days": 30
        }
      },
      {
        "field": "contact.lifecycle_stage",
        "operator": "in",
        "value": ["repeat_buyer", "loyal_customer"]
      }
    ]
  }
}

Segment Evaluation Performance

Evaluating complex segments against millions of contacts is computationally expensive. The platform uses a multi-layered optimization strategy. First, pre-computed materialized views maintain aggregate metrics (total purchases, last purchase date, lifetime value) in PostgreSQL, allowing most segment conditions to be evaluated with simple SQL WHERE clauses. Second, behavioral event conditions are evaluated against a pre-aggregated event matrix stored in Redis, where each contact's event counts and timestamps for the last 90 days are cached. Third, complex conditions involving multiple event sequences are evaluated using a streaming pipeline that processes events in order and emits segment membership changes. Fourth, segment results are cached in Redis with a 5-minute TTL for dynamic segments and a 24-hour TTL for static segments.

Segment ComplexityContactsEvaluation TimeOptimization
Simple (1 condition)1M< 100msMaterialized view
Medium (3-5 conditions)1M< 500msRedis event matrix
Complex (sequence + time window)1M< 2 secondsStreaming pipeline
Predictive (ML model)1M< 5 secondsBatch scoring + cache

15. Discount Code Integration

Discount codes are a powerful conversion lever in e-commerce marketing. The platform must generate, distribute, and track discount codes across all three channels while preventing abuse and ensuring proper revenue attribution. The discount code system integrates with the e-commerce platform's native discount/coupon functionality to create codes that are valid at checkout.

Dynamic Code Generation

The platform supports three types of discount codes. Merchant-defined codes are fixed codes that the merchant creates in their e-commerce platform (like SUMMER50) and references in campaigns. These codes have unlimited uses and a fixed expiration date. Platform-generated single-use codes are unique codes created by the platform for each recipient (like SAVE20-A8K3M2) that can only be used once. These codes are generated using a cryptographic random string with a prefix that identifies the campaign, and they are created in bulk via the e-commerce platform's discount API before campaign send. Platform-generated multi-use codes are unique codes that can be used a limited number of times (typically 100-1000 uses) and are used for broad campaigns where single-use codes are cost-prohibitive.

C#
public class DiscountCodeService
{
    private readonly IEcommercePlatform _platform;
    private readonly ICodeGenerator _codeGenerator;

    public async Task<Dictionary<Guid, string>> GenerateCodesForCampaignAsync(
        Campaign campaign, List<Guid> contactIds, DiscountConfig config)
    {
        var codes = new Dictionary<Guid, string>();

        if (config.Type == DiscountType.SingleUse)
        {
            var bulkCodes = await _platform.CreateBulkDiscountCodesAsync(
                campaign.StoreId,
                new BulkDiscountRequest
                {
                    Count = contactIds.Count,
                    Prefix = $"CAM{campaign.Id.ToString()[..6].ToUpper()}",
                    ValueType = config.ValueType,
                    Value = config.Value,
                    ExpiresAt = config.ExpiresAt,
                    UsageLimit = 1
                });

            for (int i = 0; i < contactIds.Count; i++)
                codes[contactIds[i]] = bulkCodes[i];
        }
        else if (config.Type == DiscountType.Fixed)
        {
            foreach (var contactId in contactIds)
                codes[contactId] = config.FixedCode;
        }

        return codes;
    }
}

Code Abuse Prevention

Single-use codes are inherently safe from abuse, but multi-use and merchant-defined codes require additional protections. The platform implements: per-contact usage tracking (a contact cannot use the same code more than the configured limit), per-IP rate limiting (multiple contacts from the same IP using the same code within a short window triggers an alert), velocity checks (if a code is redeemed more than 10x its expected rate, it is automatically disabled), and expiration enforcement (codes past their expiration date are not displayed in campaigns even if the campaign is still active).

Code TypeUse CaseAbuse RiskCost per Code
Single-UsePersonalized offers, win-back campaignsNone$0.01-0.05
Multi-Use (limited)Broad promotions, social mediaLow$0.005
Merchant-DefinedSite-wide sales, seasonal promotionsMediumFree
Auto-Generated (unlimited)Abandoned cart incentivesLowFree

16. Revenue Attribution

Revenue attribution is how the platform connects marketing spend to actual revenue. For e-commerce merchants, this is the ultimate measure of ROI — every dollar spent on Omnisend must be justifiable by the revenue it generated. The attribution system must handle multichannel journeys where a customer receives an email, then an SMS, then a push notification before converting, and must properly attribute credit across all touchpoints.

Attribution Models

The platform supports four attribution models. Last-touch attribution assigns 100% of the revenue to the last channel the customer interacted with before purchase. This is the simplest model but often undervalues upper-funnel channels like email. First-touch attribution assigns 100% of the revenue to the first channel the customer interacted with. Linear attribution distributes revenue equally across all channels in the customer's journey. Time-decay attribution assigns more credit to touchpoints closer to the purchase event, using an exponential decay function with a half-life of 24 hours.

Tracking Mechanisms

The platform uses three complementary tracking mechanisms. UTM parameters are appended to all links in campaigns, allowing Google Analytics and other analytics platforms to attribute traffic to specific campaigns. The platform automatically generates UTM parameters with consistent naming: utm_source=omnisend, utm_medium=email|sms|push, utm_campaign={campaign_id}, utm_content={variant_id}. Coupon code tracking associates revenue with campaigns when a customer uses a campaign-specific discount code at checkout. The platform periodically queries the e-commerce platform's order API to find orders containing campaign-specific codes and attributes the revenue accordingly. Pixel tracking embeds a 1x1 pixel in email templates that fires when the email is opened, providing open tracking independent of the email provider's tracking. For push and SMS, click tracking is implemented through redirect URLs that log the click before forwarding to the destination.

graph TB subgraph "Touchpoints" E[Email Open/Click] S[SMS Click] P[Push Click] end subgraph "Tracking" UTM[UTM Parameters] CODE[Coupon Code] PIX[Pixel Tracking] REDIR[Click Redirect] end subgraph "Attribution Engine" JOURNEY[Journey Reconstruction] MODEL[Attribution Model] ATTR[Revenue Attribution] end subgraph "Output" DASH[Dashboard] REPORT[Merchant Reports] ROI[ROI Calculation] end E --> UTM E --> PIX S --> REDIR P --> REDIR E --> CODE S --> CODE P --> CODE UTM --> JOURNEY CODE --> JOURNEY PIX --> JOURNEY REDIR --> JOURNEY JOURNEY --> MODEL MODEL --> ATTR ATTR --> DASH ATTR --> REPORT ATTR --> ROI

Attribution Window

The platform uses a configurable attribution window that defines how far back to look for touchpoints when attributing a conversion. The default window is 30 days for email, 7 days for SMS, and 1 day for push notifications. These defaults reflect the typical engagement decay patterns for each channel. Merchants can customize these windows in their store settings. Touchpoints outside the attribution window are not credited with the conversion, even if they contributed to the customer's journey.

17. A/B Testing & Optimization

A/B testing is essential for optimizing campaign performance over time. The platform must support A/B testing across all three channels, with statistical rigor to ensure that results are meaningful and not due to random chance. The testing framework covers subject lines, send times, content variations, channel selection, and discount offers.

Multichannel A/B Testing

The platform supports several A/B testing configurations. Subject line testing splits the email audience into variants that receive different subject lines, with the winning variant determined by open rate after a configurable test period (default: 2 hours). Content testing splits the audience into variants that receive different email body content, with the winning variant determined by click rate. Channel testing determines whether a specific audience segment responds better to email, SMS, or push for a given campaign type. Send-time testing splits the audience into groups that receive the same message at different times, with the winning time determined by engagement rate. Offer testing splits the audience into groups that receive different discount amounts or types, with the winning offer determined by conversion rate and revenue per recipient.

Statistical Significance

The platform uses a Bayesian approach to A/B test evaluation rather than traditional frequentist hypothesis testing. The Bayesian approach calculates the probability that each variant is the best, providing more intuitive results ("Variant A has an 94% probability of being better than Variant B") and allowing for earlier decision-making when the evidence is strong. The platform requires a minimum sample size of 1,000 recipients per variant and a minimum test duration of 2 hours before declaring a winner. The confidence threshold for declaring a winner is configurable, with a default of 95% probability.

Test TypeDefault SplitWin MetricMin. SampleTest Duration
Subject Line20% test, 80% winnerOpen Rate1,000/var2 hours
Email Content20% test, 80% winnerClick Rate1,000/var4 hours
Channel Selection50/50 email vs SMSConversion Rate2,000/var24 hours
Send Time25% per time slotEngagement Rate1,000/var48 hours
Offer Amount33/33/33Revenue/Recipient2,000/var72 hours

Send-Time Optimization

The send-time optimization engine uses historical engagement data to determine the optimal delivery window for each contact. The engine maintains a 90-day engagement profile for each contact, recording the hour-of-day and day-of-week for every email open, SMS click, and push notification click. This data is aggregated into a 168-cell matrix (24 hours x 7 days) that represents the contact's engagement probability at each time slot. When a campaign is scheduled with optimized send time, the system sorts each contact's time slots by engagement probability and selects the highest-probability slot within the campaign's delivery window.

18. Reporting Dashboard

The reporting dashboard is the merchant's primary interface for understanding campaign performance, channel effectiveness, and return on investment. The dashboard must present complex data in an accessible, actionable format while supporting drill-down for advanced users who want to investigate specific metrics.

Dashboard Architecture

graph TB subgraph "Data Collection" EV[Event Stream] DEL[Delivery Webhooks] CLICK[Click Tracking] REV[Revenue Data] end subgraph "Processing" ETL[ETL Pipeline] AGG[Aggregation Engine] ATTR[Attribution Engine] end subgraph "Storage" OLAP[(ClickHouse OLAP)] CACHE[(Redis Dashboard Cache)] end subgraph "Dashboard" REAL[Real-Time Dashboard] COMP[Campaign Reports] CHAN[Channel Comparison] ROAS[Revenue Reports] end EV --> ETL DEL --> ETL CLICK --> ETL REV --> ETL ETL --> AGG AGG --> ATTR ATTR --> OLAP AGG --> CACHE OLAP --> COMP OLAP --> CHAN OLAP --> ROAS CACHE --> REAL

Key Metrics

The dashboard presents five categories of metrics. Delivery metrics include sent count, delivered count, delivery rate, bounce rate (hard and soft), and complaint rate. Engagement metrics include open rate, click rate, click-to-open rate (CTOR), unsubscribe rate, and spam complaint rate. Conversion metrics include conversion rate, revenue per email (RPE), revenue per SMS (RPS), revenue per push (RPP), and average order value (AOV). Channel comparison metrics include per-channel delivery rates, per-channel engagement rates, per-channel revenue attribution, and cross-channel journey completion rates. Financial metrics include total campaign revenue, total message cost, return on ad spend (ROAS), and cost per acquisition (CPA).

Real-Time vs. Batch Analytics

The dashboard combines real-time and batch analytics for optimal performance. Real-time metrics (delivery rates, open rates, click rates) are computed from a streaming pipeline that processes Kafka events and stores results in Redis. These metrics update within seconds of an event occurring and are displayed on the real-time campaign monitoring dashboard. Batch metrics (revenue attribution, conversion rates, ROAS) are computed by a nightly ETL job that joins message data with order data and stores results in ClickHouse. ClickHouse is chosen for batch analytics because of its exceptional query performance on large analytical datasets — a query scanning 100 million rows completes in under 2 seconds.

Metric CategoryUpdate FrequencyStorageQuery Latency
Delivery (sent, delivered, bounced)Real-time (<5s)Redis<10ms
Engagement (opened, clicked)Near real-time (<30s)Redis + ClickHouse<50ms
Conversion (orders, revenue)Hourly batchClickHouse<500ms
Attribution (multichannel)Nightly batchClickHouse<2s
ROI (ROAS, CPA)Daily batchClickHouse<3s

19. Database Design Deep-Dive

The database layer is the foundation of the entire platform. We use PostgreSQL as the primary OLTP database for transactional data, ClickHouse for analytical workloads, Redis for caching and real-time data, and Elasticsearch for search functionality. Let us examine the critical schema designs.

Message Delivery Log Schema

SQL
CREATE TABLE message_delivery_log (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    message_id      UUID NOT NULL REFERENCES messages(id),
    contact_id      UUID NOT NULL REFERENCES contacts(id),
    store_id        UUID NOT NULL REFERENCES stores(id),
    channel         VARCHAR(10) NOT NULL CHECK (channel IN ('email','sms','push')),
    status          VARCHAR(20) NOT NULL,
    provider        VARCHAR(50) NOT NULL,
    provider_msg_id VARCHAR(255),
    cost_cents      INTEGER DEFAULT 0,
    metadata        JSONB DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    sent_at         TIMESTAMPTZ,
    delivered_at    TIMESTAMPTZ,
    opened_at       TIMESTAMPTZ,
    clicked_at      TIMESTAMPTZ,
    bounced_at      TIMESTAMPTZ,
    complained_at   TIMESTAMPTZ
);

CREATE INDEX idx_mdl_store_channel_status
    ON message_delivery_log (store_id, channel, status, created_at DESC);
CREATE INDEX idx_mdl_contact_channel
    ON message_delivery_log (contact_id, channel, created_at DESC);
CREATE INDEX idx_mdl_status_updated
    ON message_delivery_log (status, updated_at)
    WHERE status IN ('queued', 'sending');
CREATE INDEX idx_mdl_store_created
    ON message_delivery_log (store_id, created_at DESC);

Contact Profile Schema

SQL
CREATE TABLE contacts (
    id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    store_id          UUID NOT NULL REFERENCES stores(id),
    external_id       VARCHAR(255),
    email             VARCHAR(320),
    phone             VARCHAR(20),
    first_name        VARCHAR(100),
    last_name         VARCHAR(100),
    properties        JSONB DEFAULT '{}',
    tags              JSONB DEFAULT '[]',
    lifecycle_stage   VARCHAR(30) DEFAULT 'subscriber',
    lifetime_value_cents BIGINT DEFAULT 0,
    total_orders      INTEGER DEFAULT 0,
    last_order_at     TIMESTAMPTZ,
    last_activity_at  TIMESTAMPTZ,

    email_subscribed  BOOLEAN DEFAULT true,
    sms_subscribed    BOOLEAN DEFAULT false,
    push_subscribed   BOOLEAN DEFAULT false,
    email_opted_in_at TIMESTAMPTZ,
    sms_opted_in_at   TIMESTAMPTZ,
    push_subscribed_at TIMESTAMPTZ,

    created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE (store_id, email) WHERE email IS NOT NULL,
    UNIQUE (store_id, phone) WHERE phone IS NOT NULL
);

CREATE INDEX idx_contacts_properties
    ON contacts USING GIN (properties);
CREATE INDEX idx_contacts_tags
    ON contacts USING GIN (tags);
CREATE INDEX idx_contacts_store_lifecycle
    ON contacts (store_id, lifecycle_stage, last_activity_at DESC);

Partitioning Strategy

The message_delivery_log table is partitioned by month using PostgreSQL native partitioning. Each month's partition is an independent table with its own indexes, allowing the query planner to scan only relevant partitions. Old partitions (older than 12 months) are moved to cold storage (S3 with Parquet format) using a scheduled maintenance job. This keeps the active dataset small enough for efficient querying while preserving historical data for long-term analytics. The events table uses hash partitioning by store_id to distribute write load across multiple partitions and enable parallel query execution.

TablePartitioningPartition CountRetention
message_delivery_logRange (monthly)12 active + archive12 months active, 3 years archive
eventsHash (store_id)64 partitions90 days
analytics_aggregatesRange (daily)365 active1 year active, 5 years archive
workflow_stateNoneSingle tableUntil workflow completes

20. Caching Strategy

Caching is essential for achieving the sub-second latency requirements for triggered automations and real-time dashboard queries. The platform uses a multi-tier caching strategy with Redis as the primary cache layer and application-level in-memory caching for hot data.

Cache Hierarchy

graph LR REQ[Request] --> L1[L1: In-Memory Cache] L1 -->|Miss| L2[L2: Redis Cache] L2 -->|Miss| L3[L3: PostgreSQL] L3 -->|Populate| L2 L2 -->|Populate| L1 subgraph "Cache Contents" L1 --> L1D[Hot Contacts, Session Data] L2 --> L2D[Product Catalog, Segments, Recommendations] L3 --> L3D[Full Database] end

Cache Policies by Data Type

Data TypeCache LayerTTLEvictionInvalidation
Product catalogRedis15 minutesLRUProduct webhook
Contact profileRedis + Local5 min / 1 minLRUContact update event
Segment membershipRedis5 minutesLRUSegment re-evaluation
RecommendationsRedis15 min - 6 hoursLRUBehavioral event
Discount codesRedis24 hoursLFUCode redemption event
Dashboard aggregationsRedis30 secondsLRUNew event arrival
Send-time profilesRedis24 hoursLRUDaily batch job

Cache Warming Strategy

Cold starts are the enemy of latency. When the platform deploys a new release or when a Redis instance fails over, all caches are empty. The cache warming strategy addresses this by pre-loading hot data during startup. The warming job loads the top 100,000 most active contacts per store, the full product catalog for stores with active campaigns in the next 24 hours, and the segment membership results for the top 50 most-used segments. The warming job runs in the background during startup and completes within 5 minutes, gradually filling the cache as requests arrive. During the warming period, cache misses fall through to PostgreSQL without performance degradation — PostgreSQL handles approximately 10,000 queries per second per replica, which is sufficient for the reduced cache hit rate during warming.

Cache Consistency Model: The platform uses a cache-aside (lazy loading) pattern with event-driven invalidation. When data changes, the source of truth (PostgreSQL) is updated first, then a cache invalidation event is published to Kafka. The cache consumer receives the event and deletes the corresponding Redis key. The next read request will populate the cache with fresh data. This approach provides eventual consistency with a typical propagation delay of under 100 milliseconds, which is acceptable for all use cases except discount code redemption (where the database is queried directly).

21. Multi-Region Design

Omnisend-like platforms serve merchants globally and must comply with data residency requirements (GDPR in Europe, PIPEDA in Canada, LGPD in Brazil). The multi-region design must balance data locality for compliance, low-latency delivery for time-sensitive messages, and cost efficiency for batch processing.

Region Architecture

graph TB subgraph "US-East Region" US_APP[Application Cluster] US_DB[(PostgreSQL Primary)] US_REDIS[(Redis Cluster)] US_KAFKA[Kafka Cluster] end subgraph "EU-West Region" EU_APP[Application Cluster] EU_DB[(PostgreSQL Replica)] EU_REDIS[(Redis Cluster)] EU_KAFKA[Kafka Cluster] end subgraph "APAC Region" AP_APP[Application Cluster] AP_DB[(PostgreSQL Replica)] AP_REDIS[(Redis Cluster)] end US_APP --> US_DB EU_APP --> EU_DB AP_APP --> AP_DB US_DB -->|Async Replication| EU_DB US_DB -->|Async Replication| AP_DB US_KAFKA -->|MirrorMaker| EU_KAFKA US_APP -.->|gRPC| EU_APP EU_APP -.->|gRPC| AP_APP

Data Routing Strategy

When a new store is created, the platform assigns it to a primary region based on the merchant's geographic location. US and Canada stores are assigned to US-East, European stores to EU-West, and Asia-Pacific stores to APAC. The store's primary region owns all write operations for that store's data. Read operations can be served from any region, with the closest replica providing the lowest latency. Contact data, campaign data, and product data are replicated across regions using asynchronous PostgreSQL logical replication with a typical lag of under 5 seconds.

Cross-Region Message Delivery

Message delivery must respect data residency while maintaining low latency. Email delivery is routed to the nearest SendGrid or SES region — US stores use US-East SendGrid, EU stores use EU-West SendGrid. SMS delivery is routed to the carrier network closest to the recipient's phone number — a US phone number is always routed through a US SMS provider regardless of the store's primary region. Push notifications are delivered through Firebase Cloud Messaging, which handles geographic routing automatically. The key constraint is that contact PII (personally identifiable information) must not leave the region of origin unless the merchant has explicitly consented to cross-border data transfer.

Data TypeReplicationLag TargetConflict Resolution
Contact profilesAsync logical replication< 5sLast-write-wins (timestamp)
Campaign definitionsAsync logical replication< 5sLast-write-wins (timestamp)
Message delivery logsRegional only (no replication)N/AN/A
Product catalogAsync logical replication< 15sSource-of-truth is e-commerce platform
Analytics aggregatesRegional computation + nightly merge< 24 hoursAggregation merge function

22. Cost Estimation

Understanding the per-message cost structure is critical for both platform economics (pricing decisions) and merchant ROI calculations. The cost structure varies significantly by channel, provider, and volume tier.

Per-Message Cost Breakdown

ChannelProvider CostPlatform OverheadTotal CostNotes
Email (delivered)$0.0001-0.0003$0.00005$0.00015-0.00035Volume discounts above 1M/month
Email (bounced)$0.0001$0.00005$0.00015Bounced emails still incur base cost
SMS (US/Canada)$0.0075$0.0005$0.008Per segment, outbound only
SMS (UK/EU)$0.015-0.04$0.001$0.016-0.041Varies by country
SMS (IN/SEA)$0.002-0.005$0.0003$0.0023-0.0053Lower cost, higher volume
SMS (inbound)$0.0075$0.0005$0.008Two-way SMS inbound messages
Web Push$0.00001$0.000005$0.000015Nearly free, infrastructure cost only
Mobile Push (FCM)Free$0.000005$0.000005FCM is free; cost is infrastructure

Monthly Infrastructure Cost Estimation

ComponentMonthly CostScaling Factor
PostgreSQL (RDS Multi-AZ)$8,000Scales with data volume
Redis (ElastiCache Cluster)$4,000Scales with cache size
Kafka (MSK Cluster)$6,000Scales with throughput
ClickHouse (Analytics)$5,000Scales with data volume
Compute (EKS Cluster)$15,000Scales with request volume
Email Provider (SendGrid)$25,000Scales linearly with volume
SMS Provider (Twilio)$40,000Scales linearly with volume
Push Notifications (FCM)$0Free tier
CDN and Storage (S3 + CloudFront)$2,000Scales with traffic
Total Monthly Infrastructure$105,000

Revenue Economics

With 70,000 merchants at an average revenue of $16-100 per month (depending on plan and message volume), the platform generates approximately $3-8 million in monthly recurring revenue. The gross margin on email is extremely high (90%+) because email costs are negligible. SMS carries the highest variable cost, and the platform typically marks up SMS 2-3x over carrier rates. Push notifications have essentially zero variable cost and represent pure margin. The blended gross margin across all channels is approximately 70-75%, which is healthy for a SaaS platform.

Cost Optimization Strategy: The single most impactful cost optimization is reducing SMS volume through intelligent channel selection. By using the recommendation engine to determine which contacts are most likely to respond on each channel, the platform can route low-propensity contacts to cheaper channels (push or email) and reserve SMS for high-propensity contacts. This approach can reduce SMS volume by 30-40% while maintaining the same conversion rate.

23. Interview Q&A

Q1: How would you handle a situation where the email provider goes down during a major campaign send?

The system must be resilient to provider failures. When the email provider (SendGrid) returns errors or timeouts, the circuit breaker pattern trips after 5 consecutive failures, and pending messages are redirected to a backup provider (Amazon SES). The campaign service maintains a provider priority list with automatic failover. Messages that were already submitted to the failed provider but not confirmed delivered are requeued for delivery through the backup provider. The idempotency key on each message prevents duplicate delivery if the original submission actually succeeded despite the timeout.

Q2: How do you ensure exactly-once delivery for triggered automations like abandoned cart emails?

Exactly-once delivery is achieved through a combination of idempotency keys and distributed locking. When a trigger event arrives, the workflow engine first checks a deduplication table for an existing execution with the same idempotency key (composed of contact_id + workflow_id + trigger_event_id). If no existing execution is found, a new execution record is created with status "processing" and a distributed Redis lock is acquired. The message is then persisted to the durable message store and submitted to the channel provider. Only after the provider confirms receipt does the execution status update to "sent." If the process crashes at any point, the lock expires after 30 seconds and another process retries, but the idempotency key prevents duplicate message creation.

Q3: How would you design the segmentation engine to evaluate a segment with 1 million contacts in under 2 seconds?

The key is pre-computation and materialized views. For simple conditions (lifetime_value > 100), a PostgreSQL materialized view maintains pre-aggregated contact metrics that are updated every 5 minutes. For behavioral conditions (has not purchased in 60 days), an event aggregation table stores the last purchase timestamp for each contact, updated in real-time by the event processing pipeline. For complex conditions involving multiple behavioral events, a Redis-based event matrix stores each contact's event counts and timestamps for the last 90 days, allowing segment evaluation with simple Redis GET operations. The segment rule engine translates the segment definition into a combination of database queries and Redis lookups that execute in parallel, with results merged using set operations.

Q4: How would you handle a merchant who wants to send 10 million emails in 10 minutes?

This scenario — a major product launch or flash sale — requires careful capacity management. The campaign service first checks the merchant's sending quota and the platform's overall capacity. If capacity is available, the campaign is accepted and placed in a priority queue ahead of regular campaigns. The send pipeline scales horizontally by spinning up additional worker pods (pre-warmed in an auto-scaling group). Each worker pulls batches of 1,000 contacts from the campaign audience snapshot, generates personalized messages, and submits them to the email provider's API. The provider-side rate limit is the bottleneck — SendGrid's API supports approximately 50,000 messages per minute for high-volume accounts. To achieve 10 million in 10 minutes, we need to distribute across multiple SendGrid sub-accounts or use a dedicated IP pool with elevated rate limits. The campaign progress is tracked in real-time using a Redis counter that increments as each batch is confirmed by the provider.

Q5: How would you design the A/B testing framework to support multichannel tests?

The A/B testing framework assigns variants at the contact level, not the message level, to ensure consistency across channels. When a multichannel A/B test is configured (e.g., testing email vs. SMS for abandoned cart recovery), each contact is randomly assigned to a variant at enrollment time using a deterministic hash of the contact_id and experiment_id. This ensures the same contact always receives the same variant if they re-enter the workflow. The framework tracks all metrics (delivery, engagement, conversion) per variant and uses a Bayesian evaluation engine to calculate the probability that each variant is the best. The evaluation runs continuously during the test period, and when the configured confidence threshold is reached, the winning variant is automatically selected and remaining contacts receive the winning variant.

Q6: How do you handle contact identity resolution when the same person appears with different email addresses?

Contact identity resolution uses a multi-pass merge strategy. The first pass uses deterministic matching: if two contacts share the same email address (case-insensitive) or the same phone number (with E.164 normalization), they are merged. The second pass uses probabilistic matching: if two contacts in the same store share the same first name, last name, and postal code, they are flagged for review. Merged contacts inherit the union of all properties, with the most recent value winning for conflicting fields. Event history and order history from both profiles are combined. The original contact IDs are retained as aliases, ensuring that historical analytics are not lost. A merge audit log records every merge operation for debugging and reversibility.

Q7: How would you prevent a merchant from accidentally spamming their entire contact list?

The platform implements three layers of protection. First, the campaign pre-send check evaluates the target audience quality — if the estimated bounce rate exceeds 3% or the estimated complaint rate exceeds 0.05%, the campaign is blocked and the merchant is alerted with specific recommendations (clean bounces, segment by engagement). Second, frequency capping prevents any contact from receiving more than a configurable number of messages per day across all channels (default: 3 emails, 1 SMS, 2 push per day). Third, suppression list management automatically removes hard bounces, spam complainers, and unengaged contacts (no opens or clicks in 90 days) from the active sending list. These suppression lists are maintained automatically but can be overridden by the merchant with explicit acknowledgment of the risk.

Q8: How do you scale the workflow engine to handle millions of concurrent workflow enrollments?

The workflow engine uses a distributed task queue architecture. Each workflow enrollment creates a series of lightweight task records in PostgreSQL, one per step. A fleet of worker processes polls for due tasks (where resume_at <= now()) and processes them in parallel. Workers are horizontally scalable — adding more workers increases throughput linearly. Time-delayed steps (like "wait 1 hour") are implemented as task records with a future resume_at timestamp, avoiding the need for in-memory timers. The worker fleet processes approximately 100,000 tasks per minute, with each task completing in under 100 milliseconds. For Black Friday spikes, the auto-scaling group pre-scales from 20 to 200 workers based on queue depth metrics.

Q9: How would you implement real-time revenue attribution for multichannel campaigns?

Real-time revenue attribution uses a three-stage pipeline. Stage 1: When a message is clicked, the click event includes UTM parameters and a unique tracking ID. The click redirect service logs the event and forwards the user to the destination. Stage 2: When a purchase occurs, the order webhook includes the discount code (if used) and the referring URL (if UTM parameters are present). The attribution service matches the order to the campaign using either the UTM campaign ID or the discount code. Stage 3: For orders without explicit tracking, the attribution service uses a lookback window — any order from a contact who received a campaign message within the attribution window is attributed to that campaign using the configured attribution model. The real-time dashboard updates within 30 seconds of a click or purchase event.

Q10: How would you design the system to support GDPR data deletion requests?

GDPR's right to erasure requires deleting all personal data associated with a contact within 30 days of a verified request. The platform implements a two-phase deletion process. Phase 1 (immediate): The contact's PII fields (email, phone, name, properties) are overwritten with anonymized values in the primary database. The contact is added to a global suppression list to prevent future messaging. All active workflow enrollments for this contact are terminated. Phase 2 (background job, within 30 days): All message delivery logs for this contact are anonymized (retaining only channel and status for aggregate analytics, removing all content and PII). Product recommendation data is purged. Event history older than 90 days is deleted. Click and open tracking logs are anonymized. The deletion job is idempotent and auditable, producing a deletion certificate that records what was deleted and when.

Q11: How do you handle timezone-aware campaign scheduling across a global merchant base?

Timezone-aware scheduling uses a two-tier approach. The campaign defines a target send time (e.g., 10:00 AM), and each contact has a timezone derived from their IP geolocation at opt-in, their store's timezone setting, or the contact's explicitly provided timezone. When the campaign scheduler processes a scheduled campaign, it groups contacts by timezone and creates per-timezone sub-batches. Each sub-batch is scheduled for the target send time in its respective timezone. For example, a "10:00 AM" campaign send creates sub-batches for UTC-5 (EST) at 15:00 UTC, UTC-8 (PST) at 18:00 UTC, UTC+1 (CET) at 09:00 UTC, and so on. The scheduler maintains a timezone-aware job queue that releases sub-batches at the correct UTC time.

24. Full C# Implementation

The following C# implementation covers the core multichannel orchestration system. This is production-quality code demonstrating the key patterns discussed throughout the article: the orchestration pipeline, email service, SMS service, push notification service, and the product recommendation engine. The implementation uses modern C# features including records, pattern matching, dependency injection, and async/await throughout.

C#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

public enum Channel { Email, Sms, Push }

public enum MessageStatus
{
    Created, Queued, Sending, Sent, Delivered,
    Opened, Clicked, Bounced, Complained, Failed
}

public enum DiscountType { SingleUse, MultiUse, Fixed }

public record Contact(
    Guid Id, Guid StoreId, string? Email, string? Phone,
    string? FirstName, string? LastName,
    Dictionary<string, object> Properties,
    bool EmailSubscribed, bool SmsSubscribed, bool PushSubscribed,
    DateTime? LastActivityAt);

public record Product(
    Guid Id, string ExternalId, string Title, decimal Price,
    string ImageUrl, string Category, int InventoryCount);

public record Campaign(
    Guid Id, Guid StoreId, string Name,
    Dictionary<Channel, ChannelConfig> Channels,
    AudienceConfig Audience, ScheduleConfig Schedule);

public record ChannelConfig(
    Channel Channel, bool Enabled, string? Subject,
    string? Content, string? TemplateId,
    decimal? DiscountValue, DiscountType? DiscountType);

public record AudienceConfig(
    Guid? SegmentId, Guid? ExcludeSegmentId, int? MaxRecipients);

public record ScheduleConfig(
    DateTime? SendAt, bool OptimizeSendTime, bool SendInLocalTimezone);

public record Message(
    Guid Id, Guid CampaignId, Guid ContactId, Channel Channel,
    MessageStatus Status, string Content, string? ProviderMessageId,
    decimal CostCents, DateTime CreatedAt,
    DateTime? SentAt, DateTime? DeliveredAt,
    DateTime? OpenedAt, DateTime? ClickedAt);

public record WorkflowTrigger(
    string EventType, Dictionary<string, object> Constraints);

public record WorkflowStep(
    int StepIndex, Channel Channel, string Content,
    TimeSpan? DelayAfter, Dictionary<string, object>? Condition);

public record Workflow(
    Guid Id, Guid StoreId, string Name,
    WorkflowTrigger Trigger, List<WorkflowStep> Steps, bool IsActive);

public record Recommendation(
    Product Product, double Score, string Algorithm);

public record ComplianceResult(bool IsCompliant, string? Reason)
{
    public static ComplianceResult Passed() => new(true, null);
    public static ComplianceResult Failed(string reason) => new(false, reason);
}
C#
// ============================================
// Configuration Models
// ============================================

public record PlatformOptions
{
    public string EmailProviderApiKey { get; init; } = "";
    public string SmsProviderApiKey { get; init; } = "";
    public string FcmServerKey { get; init; } = "";
    public int EmailRateLimitPerMinute { get; init; } = 50000;
    public int SmsRateLimitPerMinute { get; init; } = 500;
    public int PushRateLimitPerMinute { get; init; } = 5000;
    public decimal EmailCostPerMessage { get; init; } = 0.0003m;
    public decimal SmsCostPerMessage { get; init; } = 0.008m;
    public decimal PushCostPerMessage { get; init; } = 0.00001m;
}

// ============================================
// Service Interfaces
// ============================================

public interface IContactRepository
{
    Task<Contact?> GetByIdAsync(Guid contactId, CancellationToken ct = default);
    Task<List<Contact>> GetBySegmentAsync(Guid segmentId, CancellationToken ct = default);
    Task<bool> IsSuppressedAsync(Guid contactId, Channel channel, CancellationToken ct = default);
}

public interface ICampaignRepository
{
    Task<Campaign?> GetByIdAsync(Guid campaignId, CancellationToken ct = default);
    Task SaveAsync(Campaign campaign, CancellationToken ct = default);
}

public interface IMessageRepository
{
    Task<Message> CreateAsync(Message message, CancellationToken ct = default);
    Task UpdateStatusAsync(Guid messageId, MessageStatus status,
        string? providerId = null, CancellationToken ct = default);
}

public interface IProductRepository
{
    Task<List<Product>> GetByStoreIdAsync(Guid storeId, CancellationToken ct = default);
    Task<List<Product>> GetByCategoryAsync(Guid storeId, string category,
        CancellationToken ct = default);
    Task<List<Product>> GetBestsellersAsync(Guid storeId, int count,
        CancellationToken ct = default);
}

public interface IRecommendationEngine
{
    Task<List<Recommendation>> GetRecommendationsAsync(
        Guid contactId, Guid storeId, string context,
        int count, CancellationToken ct = default);
}

public interface IEmailProvider
{
    Task<(bool Success, string ProviderMessageId, string? Error)> SendEmailAsync(
        string to, string subject, string htmlBody,
        string fromEmail, string fromName,
        Dictionary<string, string> headers,
        CancellationToken ct = default);
}

public interface ISmsProvider
{
    Task<(bool Success, string ProviderMessageId, string? Error)> SendSmsAsync(
        string to, string body, string? mediaUrl,
        CancellationToken ct = default);
}

public interface IPushProvider
{
    Task<(bool Success, string ProviderMessageId, string? Error)> SendPushAsync(
        string deviceToken, string title, string body,
        string? imageUrl, Dictionary<string, string> data,
        CancellationToken ct = default);
}

public interface IComplianceService
{
    Task<ComplianceResult> CheckSmsComplianceAsync(
        string phoneNumber, string messageType, string countryCode,
        CancellationToken ct = default);
}

public interface IDiscountCodeService
{
    Task<string?> GenerateCodeAsync(Guid storeId, DiscountType type,
        decimal value, DateTime expiresAt, CancellationToken ct = default);
}
C#
// ============================================
// Service Implementations
// ============================================

public class ComplianceService : IComplianceService
{
    private readonly ILogger<ComplianceService> _logger;

    public ComplianceService(ILogger<ComplianceService> logger)
        => _logger = logger;

    public Task<ComplianceResult> CheckSmsComplianceAsync(
        string phoneNumber, string messageType, string countryCode,
        CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(phoneNumber))
            return Task.FromResult(
                ComplianceResult.Failed("Phone number is required"));

        if (!phoneNumber.StartsWith("+"))
            return Task.FromResult(
                ComplianceResult.Failed("Phone must be in E.164 format"));

        var restrictedCountries = new HashSet<string> { "CN", "IR", "KP" };
        if (restrictedCountries.Contains(countryCode.ToUpper()))
        {
            _logger.LogWarning(
                "SMS blocked for restricted country {Country}", countryCode);
            return Task.FromResult(
                ComplianceResult.Failed($"SMS not available in {countryCode}"));
        }

        return Task.FromResult(ComplianceResult.Passed());
    }
}

public class DiscountCodeService : IDiscountCodeService
{
    private readonly ILogger<DiscountCodeService> _logger;

    public DiscountCodeService(ILogger<DiscountCodeService> logger)
        => _logger = logger;

    public Task<string?> GenerateCodeAsync(
        Guid storeId, DiscountType type, decimal value,
        DateTime expiresAt, CancellationToken ct = default)
    {
        string code = type switch
        {
            DiscountType.SingleUse => GenerateUniqueCode(storeId),
            DiscountType.MultiUse => $"MULTI{value:0}-{GenerateShortCode()}",
            DiscountType.Fixed => GenerateUniqueCode(storeId),
            _ => throw new ArgumentException($"Unknown type: {type}")
        };

        _logger.LogInformation(
            "Generated {Type} code {Code} for store {StoreId}", type, code, storeId);
        return Task.FromResult<string?>(code);
    }

    private static string GenerateUniqueCode(Guid storeId)
    {
        var prefix = storeId.ToString()[..6].ToUpperInvariant();
        var random = Convert.ToBase64String(Guid.NewGuid().ToByteArray())
            .Replace("+", "A").Replace("/", "B")[..8].ToUpperInvariant();
        return $"{prefix}-{random}";
    }

    private static string GenerateShortCode()
    {
        return Convert.ToBase64String(Guid.NewGuid().ToByteArray())
            .Replace("+", "X").Replace("/", "Y")[..6].ToUpperInvariant();
    }
}
C#
// ============================================
// Email Service
// ============================================

public class EmailService
{
    private readonly IEmailProvider _provider;
    private readonly IMessageRepository _messageRepo;
    private readonly IProductRepository _productRepo;
    private readonly IOptions<PlatformOptions> _options;
    private readonly ILogger<EmailService> _logger;

    public EmailService(
        IEmailProvider provider, IMessageRepository messageRepo,
        IProductRepository productRepo,
        IOptions<PlatformOptions> options,
        ILogger<EmailService> logger)
    {
        _provider = provider;
        _messageRepo = messageRepo;
        _productRepo = productRepo;
        _options = options;
        _logger = logger;
    }

    public async Task<Message> SendEmailAsync(
        Contact contact, Campaign campaign, ChannelConfig config,
        CancellationToken ct = default)
    {
        if (string.IsNullOrEmpty(contact.Email))
            throw new InvalidOperationException(
                $"Contact {contact.Id} has no email address");

        if (!contact.EmailSubscribed)
            throw new InvalidOperationException(
                $"Contact {contact.Id} is not subscribed to email");

        var personalizedContent = await PersonalizeContentAsync(
            config.Content ?? "", contact, campaign.StoreId, ct);

        var message = new Message(
            Id: Guid.NewGuid(), CampaignId: campaign.Id,
            ContactId: contact.Id, Channel: Channel.Email,
            Status: MessageStatus.Queued, Content: personalizedContent,
            ProviderMessageId: null,
            CostCents: _options.Value.EmailCostPerMessage,
            CreatedAt: DateTime.UtcNow, SentAt: null,
            DeliveredAt: null, OpenedAt: null, ClickedAt: null);

        message = await _messageRepo.CreateAsync(message, ct);

        var headers = new Dictionary<string, string>
        {
            ["X-Campaign-Id"] = campaign.Id.ToString(),
            ["X-Contact-Id"] = contact.Id.ToString(),
            ["List-Unsubscribe"] =
                "<mailto:unsubscribe@mystore.com?subject=unsubscribe>"
        };

        var (success, providerId, error) = await _provider.SendEmailAsync(
            contact.Email, config.Subject ?? "Message from your store",
            personalizedContent, "noreply@mystore.com",
            campaign.Name, headers, ct);

        if (success)
        {
            await _messageRepo.UpdateStatusAsync(
                message.Id, MessageStatus.Sent, providerId, ct);
            _logger.LogInformation(
                "Email sent to {Email} for campaign {CampaignId}",
                contact.Email, campaign.Id);
        }
        else
        {
            await _messageRepo.UpdateStatusAsync(
                message.Id, MessageStatus.Failed, providerId, ct);
            _logger.LogError(
                "Email failed for contact {ContactId}: {Error}",
                contact.Id, error);
        }

        return message;
    }

    private async Task<string> PersonalizeContentAsync(
        string template, Contact contact, Guid storeId,
        CancellationToken ct)
    {
        var content = template;
        content = content.Replace("{{contact.first_name}}",
            contact.FirstName ?? "there");
        content = content.Replace("{{contact.last_name}}",
            contact.LastName ?? "");
        content = content.Replace("{{contact.email}}",
            contact.Email ?? "");

        foreach (var prop in contact.Properties)
            content = content.Replace(
                $"{{{{contact.{prop.Key}}}}}", prop.Value?.ToString() ?? "");

        if (content.Contains("{{products."))
        {
            var bestsellers = await _productRepo.GetBestsellersAsync(
                storeId, 6, ct);
            for (int i = 0; i < bestsellers.Count; i++)
            {
                content = content.Replace(
                    $"{{{{products[{i}].title}}}}", bestsellers[i].Title);
                content = content.Replace(
                    $"{{{{products[{i}].price}}}}",
                    bestsellers[i].Price.ToString("C"));
                content = content.Replace(
                    $"{{{{products[{i}].image_url}}}}",
                    bestsellers[i].ImageUrl);
            }
        }
        return content;
    }
}
C#
// ============================================
// SMS Service
// ============================================

public class SmsService
{
    private readonly ISmsProvider _provider;
    private readonly IMessageRepository _messageRepo;
    private readonly IComplianceService _complianceService;
    private readonly IOptions<PlatformOptions> _options;
    private readonly ILogger<SmsService> _logger;

    public SmsService(
        ISmsProvider provider, IMessageRepository messageRepo,
        IComplianceService complianceService,
        IOptions<PlatformOptions> options,
        ILogger<SmsService> logger)
    {
        _provider = provider;
        _messageRepo = messageRepo;
        _complianceService = complianceService;
        _options = options;
        _logger = logger;
    }

    public async Task<Message> SendSmsAsync(
        Contact contact, Campaign campaign, ChannelConfig config,
        string countryCode = "US",
        CancellationToken ct = default)
    {
        if (string.IsNullOrEmpty(contact.Phone))
            throw new InvalidOperationException(
                $"Contact {contact.Id} has no phone number");

        if (!contact.SmsSubscribed)
            throw new InvalidOperationException(
                $"Contact {contact.Id} is not subscribed to SMS");

        var compliance = await _complianceService.CheckSmsComplianceAsync(
            contact.Phone, "promotional", countryCode, ct);

        if (!compliance.IsCompliant)
            throw new InvalidOperationException(
                $"SMS compliance failed: {compliance.Reason}");

        var messageBody = await PersonalizeSmsAsync(
            config.Content ?? "", contact, ct);

        var message = new Message(
            Id: Guid.NewGuid(), CampaignId: campaign.Id,
            ContactId: contact.Id, Channel: Channel.Sms,
            Status: MessageStatus.Queued, Content: messageBody,
            ProviderMessageId: null,
            CostCents: CalculateSmsCost(messageBody, countryCode),
            CreatedAt: DateTime.UtcNow, SentAt: null,
            DeliveredAt: null, OpenedAt: null, ClickedAt: null);

        message = await _messageRepo.CreateAsync(message, ct);

        var (success, providerId, error) = await _provider.SendSmsAsync(
            contact.Phone, messageBody, null, ct);

        if (success)
        {
            await _messageRepo.UpdateStatusAsync(
                message.Id, MessageStatus.Sent, providerId, ct);
            _logger.LogInformation(
                "SMS sent to {Phone} for campaign {CampaignId}",
                contact.Phone, campaign.Id);
        }
        else
        {
            await _messageRepo.UpdateStatusAsync(
                message.Id, MessageStatus.Failed, providerId, ct);
            _logger.LogError(
                "SMS failed for contact {ContactId}: {Error}",
                contact.Id, error);
        }

        return message;
    }

    private decimal CalculateSmsCost(string messageBody, string countryCode)
    {
        var segments = (int)Math.Ceiling(messageBody.Length / 160.0);
        var perSegmentCost = countryCode.ToUpper() switch
        {
            "US" or "CA" => 0.008m,
            "GB" or "DE" or "FR" => 0.03m,
            "IN" => 0.003m,
            _ => 0.02m
        };
        return segments * perSegmentCost;
    }

    private Task<string> PersonalizeSmsAsync(
        string template, Contact contact, CancellationToken ct)
    {
        var content = template;
        content = content.Replace("{{contact.first_name}}",
            contact.FirstName ?? "there");
        content = content.Replace(
            "{{contact.first_name | default: 'there'}}",
            contact.FirstName ?? "there");
        if (content.Contains("{{short_link}}"))
            content = content.Replace("{{short_link}}",
                "https://mystore.com/go");
        return Task.FromResult(content);
    }
}
C#
// ============================================
// Push Notification Service
// ============================================

public class PushNotificationService
{
    private readonly IPushProvider _provider;
    private readonly IMessageRepository _messageRepo;
    private readonly IOptions<PlatformOptions> _options;
    private readonly ILogger<PushNotificationService> _logger;

    public PushNotificationService(
        IPushProvider provider, IMessageRepository messageRepo,
        IOptions<PlatformOptions> options,
        ILogger<PushNotificationService> logger)
    {
        _provider = provider;
        _messageRepo = messageRepo;
        _options = options;
        _logger = logger;
    }

    public async Task<Message> SendPushAsync(
        Contact contact, Campaign campaign, ChannelConfig config,
        string deviceToken, CancellationToken ct = default)
    {
        if (!contact.PushSubscribed)
            throw new InvalidOperationException(
                $"Contact {contact.Id} not subscribed to push");

        var title = config.Subject ?? campaign.Name;
        var body = config.Content ?? "Check out our latest offers!";

        if (title.Length > 50)
        {
            _logger.LogWarning(
                "Push title truncated from {Len} to 50", title.Length);
            title = title[..47] + "...";
        }

        var message = new Message(
            Id: Guid.NewGuid(), CampaignId: campaign.Id,
            ContactId: contact.Id, Channel: Channel.Push,
            Status: MessageStatus.Queued,
            Content: $"{title}: {body}",
            ProviderMessageId: null,
            CostCents: _options.Value.PushCostPerMessage,
            CreatedAt: DateTime.UtcNow, SentAt: null,
            DeliveredAt: null, OpenedAt: null, ClickedAt: null);

        message = await _messageRepo.CreateAsync(message, ct);

        var data = new Dictionary<string, string>
        {
            ["campaign_id"] = campaign.Id.ToString(),
            ["contact_id"] = contact.Id.ToString(),
            ["deep_link"] = $"/campaigns/{campaign.Id}"
        };

        var (success, providerId, error) = await _provider.SendPushAsync(
            deviceToken, title, body, null, data, ct);

        if (success)
        {
            await _messageRepo.UpdateStatusAsync(
                message.Id, MessageStatus.Sent, providerId, ct);
            _logger.LogInformation(
                "Push sent for contact {ContactId}", contact.Id);
        }
        else
        {
            await _messageRepo.UpdateStatusAsync(
                message.Id, MessageStatus.Failed, providerId, ct);
            _logger.LogError(
                "Push failed for contact {ContactId}: {Error}",
                contact.Id, error);
        }

        return message;
    }
}

// ============================================
// Recommendation Engine
// ============================================

public class RecommendationEngine : IRecommendationEngine
{
    private readonly IProductRepository _productRepo;
    private readonly ILogger<RecommendationEngine> _logger;

    public RecommendationEngine(
        IProductRepository productRepo,
        ILogger<RecommendationEngine> logger)
    {
        _productRepo = productRepo;
        _logger = logger;
    }

    public async Task<List<Recommendation>> GetRecommendationsAsync(
        Guid contactId, Guid storeId, string context,
        int count, CancellationToken ct = default)
    {
        _logger.LogDebug(
            "Generating {Count} recs for contact {ContactId}, ctx: {Context}",
            count, contactId, context);

        var recs = context.ToLowerInvariant() switch
        {
            "cart" => await GetCartBasedAsync(storeId, count, ct),
            "purchase" => await GetPurchaseBasedAsync(storeId, count, ct),
            "browse" => await GetBrowseBasedAsync(storeId, count, ct),
            "trending" => await GetTrendingAsync(storeId, count, ct),
            "bestsellers" => await GetBestsellerAsync(storeId, count, ct),
            _ => await GetBestsellerAsync(storeId, count, ct)
        };

        return recs;
    }

    private async Task<List<Recommendation>> GetCartBasedAsync(
        Guid storeId, int count, CancellationToken ct)
    {
        var products = await _productRepo.GetByStoreIdAsync(storeId, ct);
        return products
            .Where(p => p.InventoryCount > 0)
            .OrderBy(_ => Random.Shared.Next())
            .Take(count)
            .Select(p => new Recommendation(p, 0.8, "cart_complementary"))
            .ToList();
    }

    private async Task<List<Recommendation>> GetPurchaseBasedAsync(
        Guid storeId, int count, CancellationToken ct)
    {
        var products = await _productRepo.GetByStoreIdAsync(storeId, ct);
        return products
            .Where(p => p.InventoryCount > 0)
            .OrderByDescending(p => p.Price)
            .Take(count)
            .Select(p => new Recommendation(p, 0.7, "purchase_similarity"))
            .ToList();
    }

    private async Task<List<Recommendation>> GetBrowseBasedAsync(
        Guid storeId, int count, CancellationToken ct)
    {
        var products = await _productRepo.GetByStoreIdAsync(storeId, ct);
        return products
            .Where(p => p.InventoryCount > 0)
            .OrderByDescending(p => p.InventoryCount)
            .Take(count)
            .Select(p => new Recommendation(p, 0.6, "browse_similarity"))
            .ToList();
    }

    private async Task<List<Recommendation>> GetTrendingAsync(
        Guid storeId, int count, CancellationToken ct)
    {
        var bestsellers = await _productRepo.GetBestsellersAsync(
            storeId, count * 2, ct);
        return bestsellers
            .Take(count)
            .Select(p => new Recommendation(p, 0.9, "trending"))
            .ToList();
    }

    private async Task<List<Recommendation>> GetBestsellerAsync(
        Guid storeId, int count, CancellationToken ct)
    {
        var bestsellers = await _productRepo.GetBestsellersAsync(
            storeId, count, ct);
        return bestsellers
            .Select(p => new Recommendation(p, 0.85, "bestseller"))
            .ToList();
    }
}
C#
// ============================================
// Multichannel Orchestrator (Core)
// ============================================

public record CampaignExecutionResult
{
    public Guid CampaignId { get; init; }
    public DateTime StartedAt { get; init; }
    public DateTime? CompletedAt { get; set; }
    public Dictionary<Channel, ChannelExecutionResult> ChannelResults { get; init; } = new();
    public int TotalContacts { get; set; }
    public int TotalMessagesSent { get; set; }
    public decimal TotalCost { get; set; }
}

public record ChannelExecutionResult
{
    public Channel Channel { get; init; }
    public int ContactsTargeted { get; set; }
    public int MessagesSent { get; set; }
    public int MessagesFailed { get; set; }
    public decimal TotalCost { get; set; }
    public List<string> Errors { get; init; } = new();
}

public class MultichannelOrchestrator
{
    private readonly EmailService _emailService;
    private readonly SmsService _smsService;
    private readonly PushNotificationService _pushService;
    private readonly IContactRepository _contactRepo;
    private readonly ICampaignRepository _campaignRepo;
    private readonly IRecommendationEngine _recommendationEngine;
    private readonly IDiscountCodeService _discountCodeService;
    private readonly ILogger<MultichannelOrchestrator> _logger;

    public MultichannelOrchestrator(
        EmailService emailService,
        SmsService smsService,
        PushNotificationService pushService,
        IContactRepository contactRepo,
        ICampaignRepository campaignRepo,
        IRecommendationEngine recommendationEngine,
        IDiscountCodeService discountCodeService,
        ILogger<MultichannelOrchestrator> logger)
    {
        _emailService = emailService;
        _smsService = smsService;
        _pushService = pushService;
        _contactRepo = contactRepo;
        _campaignRepo = campaignRepo;
        _recommendationEngine = recommendationEngine;
        _discountCodeService = discountCodeService;
        _logger = logger;
    }

    public async Task<CampaignExecutionResult> ExecuteCampaignAsync(
        Campaign campaign, CancellationToken ct = default)
    {
        _logger.LogInformation(
            "Starting campaign execution: {CampaignId} ({Name})",
            campaign.Id, campaign.Name);

        var result = new CampaignExecutionResult
        {
            CampaignId = campaign.Id,
            StartedAt = DateTime.UtcNow
        };

        var enabledChannels = campaign.Channels
            .Where(kvp => kvp.Value.Enabled)
            .Select(kvp => kvp.Key)
            .ToList();

        _logger.LogInformation(
            "Campaign {Id} has {Count} enabled channels: {Channels}",
            campaign.Id, enabledChannels.Count,
            string.Join(", ", enabledChannels));

        foreach (var channel in enabledChannels)
        {
            var channelResult = await ExecuteChannelAsync(
                campaign, channel, ct);
            result.ChannelResults[channel] = channelResult;
            result.TotalMessagesSent += channelResult.MessagesSent;
            result.TotalCost += channelResult.TotalCost;
        }

        result.CompletedAt = DateTime.UtcNow;
        var duration = result.CompletedAt.Value - result.StartedAt;

        _logger.LogInformation(
            "Campaign {Id} completed: {Messages} messages sent, " +
            "${Cost:F4} total cost, {Duration} elapsed",
            campaign.Id, result.TotalMessagesSent,
            result.TotalCost, duration.TotalSeconds);

        return result;
    }

    private async Task<ChannelExecutionResult> ExecuteChannelAsync(
        Campaign campaign, Channel channel,
        CancellationToken ct)
    {
        var channelResult = new ChannelExecutionResult
        {
            Channel = channel,
            ContactsTargeted = 0,
            MessagesSent = 0,
            MessagesFailed = 0,
            TotalCost = 0
        };

        if (!campaign.Channels.TryGetValue(channel, out var config))
            return channelResult;

        if (!config.Enabled)
            return channelResult;

        // Resolve contacts based on audience config
        var contactIds = await ResolveAudienceAsync(
            campaign.Audience, ct);

        channelResult.ContactsTargeted = contactIds.Count;

        _logger.LogInformation(
            "Channel {Channel}: {Count} contacts targeted",
            channel, contactIds.Count);

        // Process each contact
        foreach (var contactId in contactIds)
        {
            ct.ThrowIfCancellationRequested();

            var contact = await _contactRepo.GetByIdAsync(contactId, ct);
            if (contact == null)
            {
                _logger.LogWarning(
                    "Contact {Id} not found, skipping", contactId);
                continue;
            }

            // Check suppression
            if (await _contactRepo.IsSuppressedAsync(
                contactId, channel, ct))
            {
                _logger.LogDebug(
                    "Contact {Id} suppressed for {Channel}",
                    contactId, channel);
                continue;
            }

            try
            {
                Message message = channel switch
                {
                    Channel.Email => await _emailService.SendEmailAsync(
                        contact, campaign, config, ct),
                    Channel.Sms => await _smsService.SendSmsAsync(
                        contact, campaign, config, ct: ct),
                    Channel.Push => await SendPushForContactAsync(
                        contact, campaign, config, ct),
                    _ => throw new ArgumentException(
                        $"Unknown channel: {channel}")
                };

                channelResult.MessagesSent++;
                channelResult.TotalCost += message.CostCents;
            }
            catch (InvalidOperationException ex)
            {
                _logger.LogWarning(
                    "Failed to send {Channel} to {ContactId}: {Error}",
                    channel, contactId, ex.Message);
                channelResult.MessagesFailed++;
                channelResult.Errors.Add(
                    $"{contactId}: {ex.Message}");
            }
            catch (Exception ex)
            {
                _logger.LogError(ex,
                    "Unexpected error sending {Channel} to {ContactId}",
                    channel, contactId);
                channelResult.MessagesFailed++;
                channelResult.Errors.Add(
                    $"{contactId}: Unexpected error");
            }
        }

        return channelResult;
    }

    private async Task<List<Guid>> ResolveAudienceAsync(
        AudienceConfig audience, CancellationToken ct)
    {
        if (audience.SegmentId.HasValue)
        {
            var contacts = await _contactRepo.GetBySegmentAsync(
                audience.SegmentId.Value, ct);
            var contactIds = contacts.Select(c => c.Id).ToList();

            if (audience.ExcludeSegmentId.HasValue)
            {
                var excludeContacts = await _contactRepo.GetBySegmentAsync(
                    audience.ExcludeSegmentId.Value, ct);
                var excludeIds = new HashSet<Guid>(
                    excludeContacts.Select(c => c.Id));
                contactIds = contactIds
                    .Where(id => !excludeIds.Contains(id))
                    .ToList();
            }

            if (audience.MaxRecipients.HasValue)
                contactIds = contactIds
                    .Take(audience.MaxRecipients.Value)
                    .ToList();

            return contactIds;
        }

        return new List<Guid>();
    }

    private async Task<Message> SendPushForContactAsync(
        Contact contact, Campaign campaign, ChannelConfig config,
        CancellationToken ct)
    {
        // In production, retrieve device token from subscription store
        var deviceToken = contact.Properties
            .GetValueOrDefault("push_device_token")?.ToString()
            ?? throw new InvalidOperationException(
                $"Contact {contact.Id} has no push device token");

        return await _pushService.SendPushAsync(
            contact, campaign, config, deviceToken, ct);
    }
}

// ============================================
// Workflow Automation Engine
// ============================================

public class WorkflowEngine
{
    private readonly MultichannelOrchestrator _orchestrator;
    private readonly IContactRepository _contactRepo;
    private readonly ILogger<WorkflowEngine> _logger;

    public WorkflowEngine(
        MultichannelOrchestrator orchestrator,
        IContactRepository contactRepo,
        ILogger<WorkflowEngine> logger)
    {
        _orchestrator = orchestrator;
        _contactRepo = contactRepo;
        _logger = logger;
    }

    public async Task ProcessTriggerAsync(
        Workflow workflow, Guid contactId,
        Dictionary<string, object> triggerData,
        CancellationToken ct = default)
    {
        _logger.LogInformation(
            "Processing trigger for workflow {WorkflowId}, " +
            "contact {ContactId}, event {EventType}",
            workflow.Id, contactId, workflow.Trigger.EventType);

        if (!workflow.IsActive)
        {
            _logger.LogDebug(
                "Workflow {Id} is inactive, skipping", workflow.Id);
            return;
        }

        var contact = await _contactRepo.GetByIdAsync(contactId, ct);
        if (contact == null)
        {
            _logger.LogWarning(
                "Contact {Id} not found for workflow {WorkflowId}",
                contactId, workflow.Id);
            return;
        }

        foreach (var step in workflow.Steps.OrderBy(s => s.StepIndex))
        {
            ct.ThrowIfCancellationRequested();

            if (step.DelayAfter.HasValue && step.DelayAfter.Value > TimeSpan.Zero)
            {
                _logger.LogInformation(
                    "Workflow {WorkflowId}: waiting {Delay} at step {Step}",
                    workflow.Id, step.DelayAfter.Value, step.StepIndex);
                await Task.Delay(step.DelayAfter.Value, ct);
            }

            if (step.Condition != null)
            {
                var conditionMet = EvaluateCondition(
                    step.Condition, contact, triggerData);
                if (!conditionMet)
                {
                    _logger.LogDebug(
                        "Condition not met at step {Step}, skipping",
                        step.StepIndex);
                    continue;
                }
            }

            try
            {
                var campaign = new Campaign(
                    Id: Guid.NewGuid(),
                    StoreId: workflow.StoreId,
                    Name: $"{workflow.Name} - Step {step.StepIndex}",
                    Channels: new Dictionary<Channel, ChannelConfig>
                    {
                        [step.Channel] = new ChannelConfig(
                            step.Channel, true, null,
                            step.Content, null, null, null)
                    },
                    Audience: new AudienceConfig(
                        SegmentId: null, MaxRecipients: 1),
                    Schedule: new ScheduleConfig(
                        null, false, false));

                await _orchestrator.ExecuteCampaignAsync(campaign, ct);

                _logger.LogInformation(
                    "Workflow {WorkflowId}: step {Step} executed for contact {ContactId}",
                    workflow.Id, step.StepIndex, contactId);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex,
                    "Workflow {WorkflowId}: step {Step} failed for contact {ContactId}",
                    workflow.Id, step.StepIndex, contactId);
                break;
            }
        }

        _logger.LogInformation(
            "Workflow {WorkflowId} completed for contact {ContactId}",
            workflow.Id, contactId);
    }

    private bool EvaluateCondition(
        Dictionary<string, object> condition,
        Contact contact,
        Dictionary<string, object> triggerData)
    {
        if (condition.TryGetValue("field", out var fieldObj) &&
            condition.TryGetValue("value", out var valueObj))
        {
            var field = fieldObj.ToString() ?? "";
            if (field.StartsWith("contact."))
            {
                var prop = field["contact.".Length..];
                var actual = contact.Properties
                    .GetValueOrDefault(prop)?.ToString();
                var expected = valueObj.ToString();
                return string.Equals(actual, expected,
                    StringComparison.OrdinalIgnoreCase);
            }
        }

        if (condition.TryGetValue("event_performed", out var eventObj))
        {
            var eventName = eventObj.ToString() ?? "";
            return triggerData.ContainsKey(eventName);
        }

        return true;
    }
}

25. Conclusion

Building an e-commerce multichannel platform like Omnisend is one of the most rewarding engineering challenges in the SaaS space. The system touches every layer of the modern technology stack — from real-time webhook ingestion and event-driven microservices to machine learning recommendation engines and multi-region database replication. The complexity is not in any single component but in the orchestration of dozens of components working together to deliver the right message to the right customer at the right time, across three distinct channels, for 70,000 merchants and hundreds of millions of contacts.

The key architectural principles that make this system work at scale are worth summarizing. Event-driven architecture through Kafka provides the loose coupling and async processing backbone that allows each subsystem to scale independently. The write-ahead log pattern for message persistence ensures durability with no message loss — the most critical non-functional requirement. Idempotency keys and distributed locking provide exactly-once execution semantics for the workflow engine, preventing the dreaded double-send. The multi-tier caching strategy with Redis and application-level caching delivers sub-second latency for triggered automations while keeping database load manageable. Multi-region deployment with async replication satisfies data residency requirements without sacrificing read performance. And the Bayesian A/B testing framework provides statistically rigorous optimization without requiring dedicated data science resources.

The cost structure of the platform reveals important strategic insights. Email is essentially free at scale — the provider costs are negligible, and the infrastructure overhead per message is fractions of a penny. SMS is the expensive channel, with per-message costs 20-50x higher than email. This economic reality drives the importance of the intelligent channel selection engine that routes messages to the most cost-effective channel for each contact. Push notifications are nearly free, making them the highest-margin channel, but their engagement rates are lower than email and SMS, requiring careful targeting to maintain merchant ROI perception.

For engineers preparing for system design interviews, the Omnisend-like platform is an excellent case study because it touches on virtually every system design concept: data modeling with flexible schemas, API design with rate limiting, horizontal scaling with auto-scaling groups, caching with invalidation strategies, message queues with dead-letter handling, database partitioning with cold storage archival, multi-region replication with conflict resolution, compliance frameworks with audit logging, and machine learning with real-time serving. The platform also requires understanding the business domain deeply — you cannot design a good e-commerce marketing system without understanding what abandoned cart recovery means, why email deliverability matters, and how revenue attribution works.

Looking forward, the next frontier for platforms like Omnisend is AI-native campaign creation. Rather than requiring merchants to manually design email templates and write SMS copy, future platforms will use large language models to generate entire campaigns from a brief description like "create a summer sale campaign for running shoes with 30% off, targeting customers who bought athletic wear in the last 6 months." The system will generate the email HTML, compose the SMS copy, design the push notification, create the audience segment, set up the A/B test, and schedule the send — all autonomously. The multichannel orchestration engine we designed in this article provides the perfect foundation for such AI-driven automation, as it already handles the channel delivery, audience targeting, and analytics that the AI layer would orchestrate.

The e-commerce marketing automation space is projected to reach $16 billion by 2028, driven by the continued growth of direct-to-consumer brands and the increasing importance of owned marketing channels (email, SMS, push) over rented channels (social media, paid ads). Platforms that can deliver sophisticated multichannel orchestration with ease of use will capture disproportionate market share. The engineering blueprint presented in this article provides the foundation for building exactly such a platform. Whether you are starting from scratch or scaling an existing system, the patterns, trade-offs, and implementation details covered here will serve as your guide.

Key Takeaways:
  • Event-driven architecture with Kafka is the backbone for real-time multichannel orchestration.
  • Durability before delivery — always persist messages before attempting delivery through any channel.
  • SMS compliance is non-negotiable — build compliance checking into the send path, not as an afterthought.
  • Intelligent channel selection reduces costs 30-40% by routing low-propensity contacts to cheaper channels.
  • Bayesian A/B testing provides more intuitive results and faster decisions than frequentist approaches.
  • Multi-region deployment must respect data residency while maintaining low-latency delivery.
  • Pre-built workflow templates (abandoned cart, welcome, post-purchase) drive the majority of merchant value.

© 2026 Ayodhyya. All rights reserved. | System Design Series

Built for engineering leaders preparing for senior+ system design interviews.