Messaging Service
A distributed, provider-centric messaging platform built on Akka.NET with cluster sharding, Redis-backed message queues, and dynamic per-provider throttling.
Overview
The Messaging Service is a high-throughput, multi-channel messaging platform designed around providers as the central abstraction. Messages are addressed to a specific provider + sender combination, enqueued in Redis, and dispatched through provider-specific worker pools with token-bucket rate limiting and end-to-end backpressure.
Note: This is the second-generation architecture. The previous campaign/message-queue model has been replaced with a lighter-weight provider queue model where message dispatch is driven from Redis rather than database-pulled message batches.
Message Lifecycle & Event Model
Every message progresses through a well-defined lifecycle, producing an append-only event trail in the message_events table. Each event records the canonical status, a substatus (delivery code for sent, error code for failed), and the actual time the event occurred (distinct from when it was persisted to the database).
┌──────────────────┐
│ API enqueues │── message-enqueued (Status: Ready)
└────────┬─────────┘
│
┌────────▼─────────┐
│ Dispatched │── message-dispatched (Status: Dispatched)
│ to provider │
└────────┬─────────┘
│
┌──────┴──────┐
│ │
┌────▼────┐ ┌────▼──────┐
│ Provider│ │ HTTP / │
│ confirms│ │ Timeout │
│ sent │ │ Failure │
└────┬────┘ └────┬──────┘
│ │
┌────▼────┐ ┌────▼──────┐
│message- │ │ message- │
│ sent │ │ failed │
│ Sent, │ │ Failed, │
│ delivery│ │ error_code│
│ _code │ │ │
└────┬────┘ └───────────┘
│
┌────▼────────┐
│ message- │
│ delivered │
│ Sent,"deliv-│
│ ered" │
└─────────────┘
| Event Type | Canonical Status | Substatus Column | Description |
|---|---|---|---|
message-enqueued |
Ready |
— | Message accepted by the API, added to Redis dispatch queue. |
message-dispatched |
Dispatched |
— | Successfully sent to the provider's API, awaiting delivery confirmation. |
message-sent |
Sent |
delivery_code |
Provider confirmed the message was accepted (intermediate status). Delivery code is null when no delivery confirmation is available yet. |
message-delivered |
Sent |
delivery_code |
Provider confirmed final delivery outcome. delivery_code = "delivered", "undelivered", etc. |
message-failed |
Failed |
provider_error_code |
Message could not be sent. Error code = "http_429", "timeout", "circuit_open", etc. |
This model allows queries without JSON parsing: the message_status, delivery_code, and provider_error_code columns are indexed and filterable directly via the query API.
Key Concepts
| Concept | Description |
|---|---|
| Actor | The fundamental unit of computation in Akka.NET. Actors encapsulate state and behavior, communicate exclusively via asynchronous messages, and process one message at a time. This guarantees thread-safety without the need for locks. |
| Provider Config | Provider-level credentials (API keys, tokens) stored in the provider_configs table. Examples: a Twilio account, a Trumpia account, a Meta WhatsApp Business profile. |
| Provider Sender | A specific sending identity (phone number, WhatsApp sender ID, email address) owned by a provider config. Each sender can have its own rate limit (max_messages_per_second), inbound webhook URL, and delivery webhook URL. |
| Provider Key | Composite key (ProviderId, SenderAddress) that uniquely identifies a dispatch target. Used as the shard identity in the Akka cluster. |
| Message Queue | A per-provider-key FIFO queue stored in Redis. Messages are added via API and consumed by an actor shard. |
| Message Group | A logical grouping of messages defined by a CorrelationId. The system aggregates real-time delivery statistics (dispatched, sent, failed, unknown) per group and can push updates via webhooks. |
Architecture
Actor System
The service runs as a multi-node Akka.NET cluster using Cluster Sharding. Sharding is a mechanism that distributes actors across the nodes in a cluster, ensuring that for any given unique ID (like a ProviderKey), there is exactly one actor instance running in the entire cluster at any time. This prevents race conditions when reading from queues and automatically redistributes workload if a node crashes or scales horizontally.
The cluster operates with three roles:
| Akka Role | Responsibilities |
|---|---|
api |
Hosts the HTTP API endpoints (send messages, control queues, webhooks). |
dispatcher |
Runs the ProviderQueueCoordinatorActor shard — dequeues messages from Redis and routes them to provider-specific workers while enforcing rate limits. |
poller |
Runs the PollerManagerActor and ProviderPollerCoordinatorActor shard — scans for pending delivery receipts and polls provider APIs. |
A single node can serve multiple roles. Configuration is controlled by the AkkaSettings:Roles setting.
Actor Directory
The system is composed of several specialized actors that handle distinct parts of the messaging lifecycle:
| Actor | Type | Responsibility |
|---|---|---|
ProviderQueueCoordinatorActor |
Shard | Manages the dispatch lifecycle for a specific ProviderKey, fetching batches from Redis and pushing them through the throttle pipeline. |
DynamicThrottleActor |
Child | Implements token-bucket rate limiting to enforce MaxMessagesPerSecond and signals backpressure to the coordinator. |
WorkerActor |
Child Pool | Provider-specific HTTP workers (e.g., TwilioWorkerActor) that execute the actual API calls to external providers. |
PollerManagerActor |
Singleton | Periodically scans Redis for active provider keys and wakes up their respective polling coordinators. |
ProviderPollerCoordinatorActor |
Shard | Manages the polling lifecycle for a specific ProviderKey. |
ReceiptPollerActor |
Child | Provider-specific HTTP workers that poll external APIs for missing delivery receipts. |
EventBatchWriterActor |
Root | Buffers message events (dispatched, delivered, failed) and bulk-inserts them into PostgreSQL. |
WebhookNotificationActor |
Root | Pushes delivery receipt webhooks to sender-configured callback URLs. |
WebhookJournalWriterActor |
Root | Buffers outbound webhook attempts and bulk-inserts them into PostgreSQL. |
GroupStatsSnapshotActor |
Root | Periodically scans Redis for idle message groups and saves their aggregated stats to PostgreSQL. |
GroupWebhookActor |
Root | Pushes real-time group stats to client webhooks with at-most-once rate limiting. |
graph TB
subgraph "HTTP Layer (api role)"
API[Messaging API<br/>POST /api/v1/messages/send<br/>POST /api/v1/provider-queues/.../pause]
WEBHOOKS[Webhook Endpoints<br/>/api/webhooks/twilio<br/>/api/webhooks/trumpia]
end
subgraph "Redis"
DISPATCH_QUEUE[Provider Message Queues<br/>Sorted Sets per ProviderKey]
DISPATCHED_INDEX[Dispatched Messages<br/>Hash + Sorted Set per ProviderKey]
end
subgraph "Akka Cluster"
subgraph "Dispatch Shard (dispatcher role)"
COORDINATOR[ProviderQueueCoordinatorActor<br/>Sharded by ProviderKey]
THROTTLE[DynamicThrottleActor<br/>Token Bucket per provider]
WORKERS[WorkerActor Pool<br/>Round-robin per provider]
end
subgraph "Poll Shard (poller role)"
POLLER_MGR[PollerManagerActor<br/>Scans Redis]
POLLER_COORD[ProviderPollerCoordinatorActor<br/>Sharded by ProviderKey]
POLLER[ReceiptPollerActor<br/>Provider-specific HTTP poll]
end
subgraph "Core Actors (all roles)"
DB_WRITER[EventBatchWriterActor<br/>Batched DB writes]
WEBHOOK_WRITER[WebhookJournalWriterActor<br/>Batched webhook journal writes]
SNAPSHOT[GroupStatsSnapshotActor<br/>PostgreSQL snapshots]
WEBHOOK_ACTOR[GroupWebhookActor<br/>Group stats push]
end
end
subgraph "PostgreSQL"
MSG_EVENTS[message_events]
CONFIGS[provider_configs]
SENDERS[provider_senders]
TRANSACTIONS[message_transactions]
end
subgraph "Admin Website"
WEBSITE[MessagingService.Website<br/>Blazor + API]
end
API -->|Enqueue| DISPATCH_QUEUE
API -->|WakeUp signal| COORDINATOR
COORDINATOR -->|Fetch batch| DISPATCH_QUEUE
COORDINATOR -->|SendMessage| THROTTLE
THROTTLE -->|Forward| WORKERS
WORKERS -->|HTTP POST| PROVIDERS[External Provider APIs]
WORKERS -->|EventStream| DB_WRITER
WEBHOOKS -->|DeliveryReceipt| DB_WRITER
WEBHOOKS -->|WebhookNotification| WEBHOOK_WRITER
POLLER_MGR -->|Scan active providers| DISPATCHED_INDEX
POLLER_MGR -->|WakeUp| POLLER_COORD
POLLER_COORD -->|Create| POLLER
POLLER -->|Poll receipts| PROVIDERS
POLLER -->|EventStream| DB_WRITER
DB_WRITER -->|Batch flush| MSG_EVENTS
WEBSITE -.->|CRUD| CONFIGS
WEBSITE -.->|CRUD| SENDERS
WEBSITE -.->|Read| TRANSACTIONS
Actor Lifecycle & Supervision
Akka.NET enforces strict parent-child supervision and lifecycle management to ensure resource efficiency and fault tolerance:
- Dynamic Cleanup (Passivation): To conserve memory, shard coordinators (
ProviderQueueCoordinatorActorandProviderPollerCoordinatorActor) are automatically passivated (stopped) by the cluster if they remain idle pastProviderMaxIdleTimeInSeconds. Because of Akka's parent-child hierarchy, stopping a coordinator recursively stops all of its active children (e.g., the dynamic throttle actor, worker pools, and provider-specific poller actors). - Persistent Core Actors: In contrast to the dynamically spawned provider actors, core system actors (
EventBatchWriterActor,WebhookJournalWriterActor,GroupStatsSnapshotActor,GroupWebhookActor) are spawned at the root level of the actor system when the node starts. They are node-level singletons (one instance per node) that run continuously to handle global tasks like batched database writes, regardless of the node's configured roles.
API Authentication & Multi-Tenancy
The API authenticates incoming HTTP requests (such as sending messages or querying group stats) using a custom ApiKeyAuthenticationHandler. Clients must provide two HTTP headers:
x-client-id: The Tenant ID (a GUID).x-client-secret: The client's raw secret string.
To minimize latency and database load during high-throughput dispatching, the authentication handler uses an IMemoryCache to temporarily store successful verification results (defaulting to 5 minutes). This caching layer prevents the system from querying PostgreSQL and performing expensive PBKDF2 hash comparisons on every single incoming API request.
Data Flow — Message Dispatch
sequenceDiagram
participant Client
participant API as POST /api/v1/messages/send
participant Redis as Redis Queue
participant Coordinator as ProviderQueueCoordinatorActor
participant OptOut as IOptOutService
participant Throttle as DynamicThrottleActor
participant Worker as WorkerActor Pool
participant DbWriter as EventBatchWriterActor
participant DB as PostgreSQL
Client->>API: POST { providerId, senderAddress, body, ... }
API->>Redis: RPUSH provider-queue:{providerKey} [message]
API->>EventBatchWriter: Publish(MessageEnqueued) — via Redis Stream + Akka EventStream
API->>Coordinator: Tell(WakeUp)
API-->>Client: 202 { id }
Coordinator->>Coordinator: ScheduleFetch()
Coordinator->>Redis: GetMessagesToDispatchAsync(providerKey, batchSize)
Redis-->>Coordinator: [Message, Message, ...]
Coordinator->>OptOut: GetOptedOutNumbersAsync(sender, [recipients])
OptOut-->>Coordinator: [optedOutNumber, ...]
loop each message
alt Recipient opted out
Coordinator->>DbWriter: Tell(MessageFailed) (Status: OPTOUT)
else Recipient valid
Coordinator->>Throttle: Tell(SendMessage)
end
end
alt Token available
Throttle->>Worker: Forward message
Worker->>Provider API: HTTP POST
Provider API-->>Worker: 200 OK (transactionId)
Worker->>DbWriter: Tell(MessageDispatched)
DbWriter->>DB: Batch flush (every WriterFlushIntervalInSeconds)
else Queue full / HighWatermark breached
Throttle->>Coordinator: Tell(SlowDown)
Coordinator->>Coordinator: Pause fetch timer
Note over Coordinator,Throttle: Backpressure active
end
Data Flow — Delivery Receipts (Webhooks & Polling)
The platform supports two mechanisms for receiving delivery receipts: pushing via provider webhooks (preferred) and active polling (fallback).
sequenceDiagram
participant Provider as External Provider API
participant Webhook as Webhook Endpoint
participant Poller as ReceiptPollerActor
participant Redis as Redis Tracking Keys
participant DbWriter as EventBatchWriterActor
participant Notifier as WebhookNotificationActor
participant Journal as WebhookJournalWriterActor
participant Client as Client Callback URL
%% Webhook Path
alt Delivery via Webhook (Push)
Provider->>Webhook: POST /api/webhooks/{provider} (Signature Validated)
Webhook->>DbWriter: Tell(DeliveryReceiptReceived) (Publishes to EventStream)
end
%% Polling Path
alt Delivery via Polling (Pull)
Poller->>Redis: Query pending-receipts-poll-index
Redis-->>Poller: [providerMessageId, ...]
Poller->>Provider: GET /receipts?ids=...
Provider-->>Poller: [Receipt, ...]
Poller->>DbWriter: Tell(DeliveryReceiptReceived) (Publishes to EventStream)
end
%% Persistence & Notification (Common)
Note over DbWriter, Client: EventStream subscriptions handle the receipt asynchronously
par Database Persistence (FlushPipeline)
DbWriter->>DbWriter: Buffer event
DbWriter->>PostgreSQL: Batch flush via COPY to message_events
DbWriter->>Redis: RemoveFromPollIndexAsync(transactionId)
and Client Notification
DbWriter-->>Notifier: (Listens to EventStream)
Notifier->>Client: POST delivery payload to callbackUrl
Client-->>Notifier: 200 OK
Notifier->>Journal: Tell(WebhookNotificationRecord)
Journal->>PostgreSQL: Batch flush via COPY to webhook_notifications
end
The EventBatchWriterActor & FlushPipeline
The EventBatchWriterActor is a critical singleton actor in the architecture. Rather than writing to PostgreSQL on every individual messaging event (which would overwhelm the database under high load), this actor acts as an asynchronous write-ahead buffer in memory.
It subscribes to the Akka EventStream for three types of events:
- Enqueues (
MessageEnqueued) — sent by the API when a message is accepted and added to the dispatch queue. - Dispatches (
MessageDispatched) — sent by worker actors after a successful HTTP call. - Failures (
MessageFailed) — sent by worker actors after an unsuccessful HTTP call, or by pollers on timeout. - Delivery Receipts (
DeliveryReceiptReceived) — forwarded from webhook endpoints and poller actors.
Every WriterFlushIntervalInSeconds (or when the internal buffer exceeds EventBatchWriterMaxPendingBeforeForceFlush), the actor triggers the scoped IFlushPipeline to perform the following in a single transaction:
- Receipt Deduplication: Ensures multiple webhooks for the same transaction don't duplicate events.
- Classification: Converts the raw events into structured
MessageEventobjects with the canonicalMessageStatus,DeliveryCode(substatus for sent), andProviderErrorCode(substatus for failed) — all derived from the source event data rather than requiring JSON parsing at query time. - Database Bulk Insert: Executes a highly optimized batched flush via PostgreSQL
COPYfor the raw event history. - Group Statistics Aggregation: Calculates the
Dispatched,Sent,Failed, andUnknowndeltas for any messages that belong to aCorrelationIdgroup. - Redis Cache Updates:
- Uses
HINCRBYto apply the calculated deltas to the real-timegroup-statsHash in Redis. - Calls
RemoveFromPollIndexAsyncto delete processed receipts from the polling queue (while leaving them in the timeout/payload indices for subsequent webhooks).
- Uses
- Notification Publishing: For every group that had statistics changed, it publishes an
EventsWriterEvents.GroupStatsUpdatedevent back to theEventStream. TheGroupWebhookActorlistens to this event to push real-time webhooks back to the client.
By decoupling the high-throughput incoming events from the actual database writes, the EventBatchWriterActor ensures the system remains highly responsive and prevents database bottlenecks.
Data Flow — Provider Queue Control
| Endpoint | Action |
|---|---|
POST /api/v1/provider-queues/{providerId}/senders/{senderAddress}/pause |
Pauses the coordinator for this provider key. In-flight messages complete; no new batches are fetched from Redis. |
POST /api/v1/provider-queues/{providerId}/senders/{senderAddress}/resume |
Resumes batch fetching. |
POST /api/v1/provider-queues/{providerId}/senders/{senderAddress}/stop |
Fire-and-forget stop for the coordinator. Messages already in Redis remain; no new dispatches begin. |
All control endpoints return 202 Accepted. Pause and Resume wait up to 5 seconds for the actor to confirm the state transition.
API Reference
Send a Single Message
POST /api/v1/messages/send
X-Api-Key: <key>
Content-Type: application/json
{
"providerId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"senderAddress": "+15550001111",
"destinationAddress": "+15559998888",
"body": "Hello from the platform!",
"mediaUrls": ["https://cdn.example.com/photo.jpg"],
"correlationId": "my-ref-42",
"callbackUrl": "https://my-server/hooks/status"
}
// 202 Accepted — single message returns the assigned message ID
{ "id": "550e8400-e29b-41d4-a716-446655440000" }
The message is enqueued in Redis and a WakeUp signal is sent to the coordinator shard for the matching providerKey.
Message Group Tracking
Clients can group messages into campaigns or logical groups using the CorrelationId field in the send API. The messaging engine automatically tracks aggregated real-time statistics (dispatched, sent, failed, unknown) per group in Redis.
POST /api/v1/groups
X-Api-Key: <key>
Content-Type: application/json
{
"correlationId": "my-campaign-123",
"callbackUrl": "https://my-server.com/webhooks/group-stats"
}
// 201 Created
{
"correlationId": "my-campaign-123",
"tenantId": "...",
"callbackUrl": "https://my-server.com/webhooks/group-stats",
"createdAt": "2023-10-01T10:00:00Z",
"updatedAt": "2023-10-01T10:00:00Z",
"dispatched": { "count": 0 },
"sent": { "count": 0, "substatuses": {} },
"failed": { "count": 0, "substatuses": {} },
"unknown": { "count": 0 }
}
GET /api/v1/groups/my-campaign-123/stats
X-Api-Key: <key>
Group stats are tracked in a Redis Hash. When stats are modified, a GroupStatsUpdated event triggers the GroupWebhookActor to push the stats via an HTTP POST to the registered callbackUrl, throttled using Redis SETNX (at-most-once per configured interval). Idle groups are eventually snapshotted into PostgreSQL by the GroupStatsSnapshotActor to preserve history.
Query Message Events
GET /api/v1/events?correlationId=my-campaign-123&page=1&pageSize=50
X-Api-Key: <key>
// 200 OK
{
"items": [
{
"eventId": "550e8400-e29b-41d4-a716-446655440000",
"outboundMessageId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"eventType": "message-enqueued",
"status": "Ready",
"deliveryCode": null,
"providerErrorCode": null,
"correlationId": "my-campaign-123",
"occurredAt": "2026-06-29T12:34:50Z"
},
{
"eventId": "550e8400-e29b-41d4-a716-446655440001",
"outboundMessageId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"eventType": "message-dispatched",
"status": "Dispatched",
"deliveryCode": null,
"providerErrorCode": null,
"correlationId": "my-campaign-123",
"occurredAt": "2026-06-29T12:34:52Z"
},
{
"eventId": "550e8400-e29b-41d4-a716-446655440002",
"outboundMessageId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"eventType": "message-delivered",
"status": "Sent",
"deliveryCode": "delivered",
"providerErrorCode": null,
"correlationId": "my-campaign-123",
"occurredAt": "2026-06-29T12:35:10Z"
}
],
"totalCount": 3,
"page": 1,
"pageSize": 50,
"totalPages": 1
}
Returns the full event timeline for one or more messages. At least one of correlationId or outboundMessageId is required.
| Parameter | Type | Description |
|---|---|---|
correlationId |
string | Filter by correlation ID (batch identifier). |
outboundMessageId |
GUID | Filter by a specific message ID. |
eventType |
string | Filter by event type (e.g. message-delivered). |
status |
int | Filter by message status enum value (0=Ready, 1=Dispatched, 2=Sent, 5=Failed, 6=Unknown). |
from |
ISO date | Start of occurred-at range. |
to |
ISO date | End of occurred-at range. |
page |
int | Page number (default 1). |
pageSize |
int | Items per page (default 50, max 500). |
sort |
string | Sort order: asc or desc (default desc). |
Get Message Summary
GET /api/v1/events/summary?correlationId=my-campaign-123&page=1&pageSize=50
X-Api-Key: <key>
// 200 OK
{
"items": [
{
"outboundMessageId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"status": "Sent",
"deliveryCode": "delivered",
"providerErrorCode": null,
"occurredAt": "2026-06-29T12:35:10Z"
},
{
"outboundMessageId": "6ba7b810-9dad-11d1-80b4-00c04fd430c9",
"status": "Failed",
"deliveryCode": null,
"providerErrorCode": "http_429",
"occurredAt": "2026-06-29T12:35:05Z"
}
],
"totalCount": 2,
"page": 1,
"pageSize": 50,
"totalPages": 1
}
Returns the latest event per unique outbound message ID within a correlation group. Uses DISTINCT ON for efficient one-row-per-message aggregation.
| Parameter | Type | Description |
|---|---|---|
correlationId |
string | Required. The correlation ID to summarize. |
page |
int | Page number (default 1). |
pageSize |
int | Items per page (default 50, max 500). |
Send Bulk Messages
POST /api/v1/messages/bulk-send
X-Api-Key: <key>
Content-Type: application/json
[
{
"providerId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"senderAddress": "+15550001111",
"destinationAddress": "+15559998888",
"body": "First message"
},
{
"providerId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"senderAddress": "+15550001111",
"destinationAddress": "+15558887777",
"body": "Second message"
}
]
// 202 Accepted — bulk send returns an array of assigned message IDs
[
"550e8400-e29b-41d4-a716-446655440000",
"6ba7b810-9dad-11d1-80b4-00c04fd430c8"
]
Messages are grouped by (ProviderId, SenderAddress) and enqueued in Redis in bulk. A single WakeUp is sent per unique provider key.
Redis Workflow
A single message touches six Redis key patterns across its lifecycle (if sent as part of a group). Each pattern serves a distinct purpose in the dispatch → track → poll → cleanup pipeline.
Key Patterns
| Pattern | Type | Purpose |
|---|---|---|
provider-queue:{providerId}:{sender} |
List | FIFO dispatch queue. Messages are RPUSHed by the API and LPOPed by the dispatcher. |
pending-receipts-index:{providerId}:{sender} |
Sorted Set | Timeout index. Score = dispatch Unix timestamp. Members are providerMessageIds. Used by pollers to find messages past DispatchedMessageTimeoutInSeconds. |
pending-receipts-poll-index:{providerId}:{sender} |
Sorted Set | Poll scheduling index. Score = last poll time. Used by pollers to find messages eligible for the next poll cycle (score ≤ now − StartPollingAfterSeconds). |
pending-receipts-data:{providerId}:{sender} |
Hash | Dispatched message payloads. Field = providerMessageId, value = serialized MessageDispatched JSON. Enables webhooks and pollers to resolve context. |
tx-provider:{providerMessageId} |
String | Reverse lookup. Maps a providerMessageId back to its providerKey. Enables webhook receipt handlers to find the correct tracking keys. |
group-stats:{tenantId}:{correlationId} |
Hash | Real-time aggregated statistics (dispatched, sent, failed, unknown) for a message group. Incremented during dispatch and FlushPipeline persistence. |
Lifecycle Phases
Phase 1 — Enqueue
- Client calls
POST /api/v1/messages/sendwith aCorrelationId. - API
HINCRBYthedispatchedcount on thegroup-statsHash. - API serializes the message and
RPUSHes it onto theprovider-queue:{providerKey}List. - API publishes a
MessageEnqueuedevent to Redis Stream (events:enqueues) and the Akka EventStream — this signals theEventBatchWriterActorto persist the event withmessage_status = Ready. - API sends a
WakeUpsignal to theProviderQueueCoordinatorActorshard.
Phase 2 — Dequeue & Dispatch
ProviderQueueCoordinatorActordoes an atomicLPOPto pull a batch from the dispatch List.- Messages flow through
DynamicThrottleActor→MessageWorkerActorBase. - On successful dispatch, the worker calls
PublishDispatchedAsync, which writes to four tracking keys and a stream in a single Redis batch:pending-receipts-indexSorted Set — addsproviderMessageIdwith score = current Unix timestamppending-receipts-poll-indexSorted Set — same entry, used by pollers for schedulingpending-receipts-dataHash — stores the fullMessageDispatchedpayloadtx-providerString — stores the serializedProviderKey, TTL = 3 daysprovider-wakeupStream — triggers thePollerManagerActorto start polling
Phase 3 — Delivery Receipts (Webhook or Poll)
- Webhook: The external provider POSTs a webhook. The API controller publishes a
DeliveryReceiptReceivedevent to theEventStream. - Poll: The
ReceiptPollerActorBaseperiodically queriespending-receipts-poll-index, reads the payload frompending-receipts-data, fetches status from the provider, and publishesDeliveryReceiptReceivedto theEventStream. - Persistence: The
EventBatchWriterActorconsumes these events and executes theFlushPipeline.- It performs the database
COPYflush. - It
HINCRBYs the appropriate sent/failed counters in thegroup-statsHash. - It calls
RemoveFromPollIndexAsyncto delete the transaction frompending-receipts-poll-index. - Note: the message is intentionally left in
pending-receipts-indexandpending-receipts-dataso subsequent webhooks for the same message can still resolve its context.
- It performs the database
Phase 4 — Timeout & Cleanup
- The
ReceiptPollerActorBasequeriespending-receipts-indexfor messages older thanDispatchedMessageTimeoutInSeconds. - For each timed-out message, it publishes a
MessageFailed(timeout) event and callsRemoveAsync. RemoveAsyncfinally deletes the entry from all four keys (index,poll-index,data, andtx-provider).
Client Redis Akka Actors Provider API
────── ───── ─────────── ────────────
│ │ │ │
│ POST /send │ │ │
├────────────────────────────────┤ │ │
│ RPUSH provider-queue:{key} │ │ │
│ 202 { id } │ │ │
│◄───────────────────────────────┤ │ │
│ │ │ │
│ │ LPOP batch │ │
│ │◄───────────────────────────────────┤ ProviderQueueCoordinator │
│ │ [Message, Message, ...] │ │
│ ├───────────────────────────────────►│ │
│ │ │ │
│ │ │ HTTP POST /messages │
│ │ ├──────────────────────────────►│
│ │ │◄──────────────────────────────┤
│ │ │ 200 { transactionId } │
│ │ │ │
│ │ BATCH: │ │
│ │ ZADD pending-receipts-index │ │
│ │ ZADD pending-receipts-poll-index │ │
│ │ HSET pending-receipts-data │ │
│ │ SET tx-provider:{msgId} │ │
│ │◄───────────────────────────────────┤ │
│ │ │ │
│ │ ... time passes ... │ │
│ │ │ │
│ │ SCAN pending-receipts-index:* │ │
│ │◄───────────────────────────────────┤ PollerManager │
│ │ ZRANGEBYSCORE (timeouts) │ │
│ │◄───────────────────────────────────┤ ReceiptPollerActor │
│ │ ZRANGEBYSCORE (poll candidates) │ │
│ │◄───────────────────────────────────┤ │
│ │ HGET (message payloads) │ │
│ │◄───────────────────────────────────┤ │
│ │ │ │
│ │ │ GET /receipts?ids=... │
│ │ ├──────────────────────────────►│
│ │ │◄──────────────────────────────┤
│ │ │ [DeliveryReceipt, ...] │
│ │ │ │
│ │ ZREM + HDEL + DEL (cleanup) │ │
│ │◄───────────────────────────────────┤ │
Provider Implementations
| Provider | Worker Actor | Receipt Poller | Channel |
|---|---|---|---|
| Trumpia | TrumpiaWorkerActor |
TrumpiaReceiptPollerActor |
SMS/MMS |
| Twilio | TwilioWorkerActor |
TwilioReceiptPollerActor |
SMS/MMS |
| Telnyx | TelnyxWorkerActor |
TelnyxReceiptPollerActor |
SMS/MMS |
Note: The
Meta(WhatsApp) provider type and
Providers are registered in the ProviderFactory during startup and use dynamic pool sizing: poolSize = max(MinWorkersPoolSize, MessagesPerSecond × EstimatedLatencyInSeconds).
Delivery Receipts
The platform supports two receipt collection mechanisms:
1. Webhooks (Push — Preferred)
External providers POST delivery status updates to:
| Webhook | Endpoint | Validation |
|---|---|---|
| Twilio | POST /api/webhooks/twilio |
HMAC-SHA1 X-Twilio-Signature validation using the dynamic AuthToken from provider_configs.credentials |
| Telnyx | POST /api/webhooks/telnyx |
Ed25519 signature validation via telnyx-signature-ed25519 and telnyx-timestamp headers, verified against the PublicKey from provider_configs.credentials |
| Trumpia | POST /api/webhooks/trumpia |
— |
Webhook updates are forwarded to EventBatchWriterActor for batched persistence.
2. Polling (Pull — Fallback)
The PollerManagerActor periodically scans Redis for active provider keys (providers with pending dispatched messages). For each active key, the ProviderPollerCoordinatorActor shard creates a provider-specific ReceiptPollerActor that queries the external API for delivery status.
Configuration:
StartPollingAfterSeconds: How long after dispatch before polling begins.DeliveryReceiptPollIntervalInSeconds: How often the scan runs.DispatchedMessageTimeoutInSeconds: Messages without a receipt after this time are markedUnknown.
Dynamic Throttling
The DynamicThrottleActor implements a token bucket algorithm per provider sender:
MaxMessagesPerSecond: Configured per sender inprovider_senders.max_messages_per_second.- HighWatermark × MaxMessagesPerSecond: When in-flight + queued messages exceed this, the throttle sends
SlowDownto the coordinator, which pauses batch fetching. - LowWatermark × MaxMessagesPerSecond: When both metrics drop below this, the throttle sends
ResumeSpeedand fetching resumes.
This creates true end-to-end backpressure — no polling, no busy-waiting.
Project Structure
src/
├── MessagingService.AppHost/ # .NET Aspire app host
├── MessagingService.ServiceDefaults/ # OpenTelemetry, health checks, service defaults
├── MessagingService.Shared/ # Shared DTOs (MessageDto), enums (ProviderType, MessageStatus)
│
├── service/
│ ├── MessagingService.Api/ # HTTP API layer (endpoints, auth, OpenAPI)
│ ├── MessagingService.Contracts/ # Shared models (ProviderKey, ProviderConfig, Message events, repository interfaces)
│ ├── MessagingService.Core/ # Core actor base classes, messages, settings, provider factory
│ ├── MessagingService.Database/ # Repository implementations (PostgreSQL + Redis)
│ ├── MessagingService.Dispatching/ # Dispatch shard: ProviderQueueCoordinatorActor + DynamicThrottleActor
│ ├── MessagingService.Polling/ # Poll shard: PollerManagerActor + ProviderPollerCoordinatorActor
│ │
│ ├── providers/
│ │ ├── MessagingService.Trumpia/ # Trumpia worker + poller actors
│ │ ├── MessagingService.Twilio/ # Twilio worker + poller actors
│ │ └── MessagingService.Telnyx/ # Telnyx worker + poller actors
│ │
│ └── simulator/
│ ├── MessageService.MessageBrokerSimulator/ # Fake provider API for integration testing
│ └── MessagingService.StressSimulator/ # High-load traffic generator for stress testing
│
└── website/
├── MessagingService.Website.Api/ # Admin CRUD API (providers, senders, tenants, messages)
├── MessagingService.Website.Application/ # Application services
├── MessagingService.Website.Application.Contracts/ # DTOs and service interfaces
├── MessagingService.Website.Domain/ # EF Core entities (ProviderConfig, ProviderSender, MessageTransaction, etc.)
├── MessagingService.Website.Data.EntityFrameworkCore/ # EF Core DbContext, migrations, repositories
├── MessagingService.Website.Components/ # Shared Blazor components
└── MessagingService.Website.Blazor/ # Blazor admin frontend
tests/
└── MessagingService.Tests/ # Unit + integration tests
Database Model
Core Tables
| Table | Schema | Purpose |
|---|---|---|
tenants |
ms |
Multi-tenant configuration |
tenant_authentication |
ms |
Per-tenant API secrets |
provider_configs |
ms |
Provider credentials and type |
provider_senders |
ms |
Sender addresses per provider with per-sender rate limits |
message_transactions |
ms |
Message dispatch log with status tracking |
attachments |
ms |
Media file metadata linked to messages |
message_events |
ms |
Append-only message event journal. Records the full timeline of each message — enqueued, dispatched, sent, delivered, failed — with canonical status, delivery code, and provider error code columns for queryability without JSON parsing. Bulk-written via PostgreSQL COPY. Indexed on correlation_id, outbound_message_id, occurred_at, and (tenant_id, occurred_at). Created dynamically at startup. |
webhook_notifications |
ms |
Webhook notification journal — logs outbound callback attempts (dispatched, delivered, failed) with request/response payloads, bulk-written via PostgreSQL COPY (created dynamically at startup). |
message_group_stats_history |
ms |
Auto-snapshotted historical data for idle message groups tracked by CorrelationId (created dynamically at startup). |
Key Relationships
tenants ──┬── tenant_authentication
└── message_transactions
provider_configs ──┬── provider_senders
└── message_transactions
provider_senders ── message_transactions
message_transactions ── attachments
Full schema details are available in the ms-service.dbml file in the root directory or generated EF Core migrations.
Configuration Reference
Most tuning parameters live under the MessagingServiceSettings key in appsettings.json, while Akka.NET cluster configuration lives under the top-level AkkaSettings key.
Environment Variable Mapping
ASP.NET Core maps colons (:) to double underscores (__) for environment variable overrides:
MessagingServiceSettings__Database__WriterFlushIntervalInSeconds=2
MessagingServiceSettings__Throttler__HighWatermark=5
Database (MessagingServiceSettings:Database)
| Parameter | Default | Description |
|---|---|---|
TimeoutInSeconds |
300 |
Max duration for a single database query. |
WriterFlushIntervalInSeconds |
5 |
How often EventBatchWriterActor bulk-flushes in-memory message events (dispatches, receipts) to PostgreSQL. |
WebhookJournalFlushIntervalInSeconds |
2 |
How often WebhookJournalWriterActor bulk-flushes webhook notification records to PostgreSQL. |
EventBatchWriterMaxPendingBeforeForceFlush |
1000 |
Max pending events (dispatches + receipts + failures) before forcing an immediate flush, regardless of the flush interval. |
WebhookJournalMaxPendingBeforeForceFlush |
1000 |
Max pending webhook notification records before forcing an immediate flush. |
Throttle Watermarks (MessagingServiceSettings:Throttler)
Multiplied against a sender's max_messages_per_second to derive pause/resume thresholds.
| Parameter | Default | Description |
|---|---|---|
HighWatermark |
5 |
In-flight + queued exceeds MaxMPS × HighWatermark → producers pause. |
LowWatermark |
2 |
Both counts drop below MaxMPS × LowWatermark → producers resume. |
Lifecycle (MessagingServiceSettings:Lifecycle)
Important:
DispatchedMessageTimeoutInSecondsmust always be greater than bothStartPollingAfterSecondsandDeliveryReceiptPollIntervalInSeconds. Otherwise messages time out before the poller can recover their status.
| Parameter | Default | Description |
|---|---|---|
ProviderMaxIdleTimeInSeconds |
1800 |
Shuts down a provider's entire actor tree after this many seconds of inactivity. |
DispatchedMessageTimeoutInSeconds |
540 |
A Dispatched message with no receipt after this many seconds is marked Unknown. |
DispatchedMessageCacheTtlInSeconds |
259200 |
The Time-To-Live (TTL) in seconds for dispatched message cache entries in Redis. Must be greater than timeout. |
StartPollingAfterSeconds |
100 |
Delay after dispatch before the poller actively queries the provider API. |
Circuit Breaker (MessagingServiceSettings:CircuitBreaker)
Applied to both worker and poller actors per provider.
| Parameter | Default | Description |
|---|---|---|
MaxFailures |
5 |
Consecutive failures to trip the breaker. |
CallTimeoutInSeconds |
10 |
Max duration for a single HTTP call. |
ResetTimeoutInSeconds |
60 |
How long the breaker stays open before testing recovery. |
Provider Integration (MessagingServiceSettings:ProviderIntegration)
| Parameter | Default | Description |
|---|---|---|
EstimatedLatencyInSeconds |
2 |
Estimated round-trip latency, used for dynamic worker pool sizing. |
MinWorkersPoolSize |
5 |
Minimum worker actor pool size per provider. |
DeliveryReceiptPollIntervalInSeconds |
120 |
How often PollerManagerActor scans Redis for active providers. |
DeliveryReceiptPollBatchSize |
500 |
Number of message IDs per poll request to the external provider. |
DeliveryReceiptPollMaxDegreeOfParallelism |
10 |
Max concurrent HTTP connections for receipt polling. |
Group Stats (MessagingServiceSettings:GroupStats)
Settings controlling group stats tracking via Redis HINCRBY counters and PostgreSQL auto-snapshots.
| Parameter | Default | Description |
|---|---|---|
TtlHours |
(required) | The TTL in hours for the Redis key storing group statistics. Must be configured explicitly. |
SnapshotAfterIdleHours |
3 |
The number of hours a group can remain idle before it is auto-snapshotted to PostgreSQL. |
SnapshotScanIntervalHours |
1 |
The interval in hours at which the background snapshot actor scans Redis for idle groups. |
WebhookThrottleSeconds |
30 |
The minimum interval in seconds between webhook notifications for the same group (at-most-once throttling). |
Service Endpoints (MessagingServiceSettings)
| Parameter | Description |
|---|---|
MediaBaseUrl |
Base URL used for resolving linked media attachments in outbound MMS messages. |
WebhooksBaseUrl |
Base URL used for constructing incoming webhook notification callbacks. |
Metrics (MessagingServiceSettings:Metrics)
| Parameter | Default | Description |
|---|---|---|
PublishIntervalInSeconds |
2 |
How often the metrics collector publishes snapshots to the Akka EventStream. |
Akka Cluster (AkkaSettings)
This configuration section is bound to the AkkaSettings top-level key in appsettings.json.
| Parameter | Default | Description |
|---|---|---|
HostName |
(dynamic) | Public hostname/advertised address for cluster discovery. Defaults to the local IPv4 address. |
RemotePort |
14884 |
Akka.Remote TCP port. |
ManagementPort |
18558 |
Akka.Management HTTP port. |
Roles |
api,dispatcher,poller |
Comma-separated list of node roles (api, dispatcher, poller). |
DiscoveryServiceName |
messaging-service-discovery |
Service name for DNS or config-based cluster bootstrap. |
ConfigDiscoveryEndpoints |
[] |
List of endpoints used for static service discovery (e.g., local development). |
RequiredContactPointsNr |
2 |
Number of nodes that must be discovered before the cluster is fully formed. Set to 1 for local single-node development. |
Deployment
The service is designed for containerized deployment (Docker / Kubernetes). Nodes declare their roles via the AkkaSettings:Roles environment variable and join an Akka.NET cluster via bootstrap discovery.
Minimal Node Types
| Node | Roles | Description |
|---|---|---|
| API Gateway | api |
Public HTTP endpoint — enqueues messages, receives webhooks. |
| Dispatcher | dispatcher |
Picks up messages from Redis and dispatches them through the throttle/worker pipeline. |
| Poller | poller |
Scans for pending delivery receipts and polls provider APIs. |
A combined node with api,dispatcher,poller works for single-instance deployments.
Dependencies
- PostgreSQL — Message event journal, provider configs, sender addresses, message transaction log.
- Redis — Per-provider message queues, dispatched message tracking index.
- Akka.NET Cluster — Service discovery and shard coordination between nodes.