Email API

Event-driven architecture for multi-channel communications

How to normalize webhooks across email, SMS, WhatsApp, and voice into a single event layer your application can reason about. Includes real Telnyx webhook payloads and code examples.

event-driven communications architecture

Sending a message is usually the easy part.

The harder part starts after you add several communication channels to the same application.

An email is delivered. A customer replies by SMS. A WhatsApp message is read. A phone call is answered.

Each of those actions creates an event your application may need to process. As you add channels, it is easy to end up with separate webhook handlers, different data models, duplicated customer lookups, and business logic spread across several integrations.

An event-driven communications architecture gives you another option. Instead of building separate workflows around email, SMS, WhatsApp, and voice, you route communication events into a common application layer and decide what happens next from there.

This guide covers how to build one: what to normalize, what to keep channel-specific, how to handle webhooks reliably, and where AI agents fit in.

What is an event-driven communications architecture?

An event is a record that something happened. For communications, that might be:

email.received
email.delivered
email.bounced

message.received
message.sent
message.finalized

call.initiated
call.answered
call.hangup

Webhooks allow your communications provider to send those events to your application as they happen.

Telnyx Email API sends webhook events for queued, sent, delivered, opened, clicked, bounced, deferred, failed, unsubscribed, complained, and inbound email. Telnyx Messaging uses webhook events such as message.received for inbound SMS. Programmable Voice generates events around the lifecycle of calls. WhatsApp webhooks notify applications about inbound messages and events such as message.delivered, message.read, and message.failed.

An event-driven architecture takes these individual channel events and turns them into inputs for the rest of your application.

A simplified architecture looks like this:

Event-driven communications architecture

The key design decision is the middle layer: how you transform raw channel events into something your application can reason about.

Why not just process each webhook separately?

You can. For simple applications, that may even be the right choice. If your system sends transactional email and nothing else, there is little reason to build an elaborate event abstraction.

The problem appears when several channels start participating in the same customer journey. Imagine this workflow:

  1. A customer receives an email.
  2. The email bounces.
  3. Your application sends an SMS instead.
  4. The customer replies.
  5. The issue requires immediate help, so your application initiates a call.

Without a shared event layer, you end up implementing the same logic several times. Your email handler identifies the customer. Your SMS handler identifies the customer again. Your voice handler does its own lookup and workflow rules.

Now add analytics, consent checks, CRM updates, AI agents, retries, and customer history. The complexity grows quickly.

The goal of an event layer is to prevent your channel integrations from becoming your business logic.

Where AI agents fit

This architecture becomes particularly useful when an AI agent needs to communicate across channels.

Instead of telling an agent to use Email API A, then call SMS API B, then use Voice API C, your application can expose communications as tools:

tools = [
    {"name": "send_email", "description": "Send an email to a customer"},
    {"name": "send_sms", "description": "Send an SMS to a customer"},
    {"name": "send_whatsapp", "description": "Send a WhatsApp message"},
    {"name": "place_call", "description": "Initiate a voice call"},
]

The agent receives the customer context and chooses an action. For example, if a customer emailed about a delayed delivery, the order is three days late, and the customer previously requested urgent updates by SMS, the agent could decide to reply to the email, send an SMS update, and escalate to a voice call if the customer requests immediate assistance.

The important part is that the application carries the context. The communications APIs provide the channels and events. This distinction matters because simply using the same communications provider does not automatically create shared customer context. Your application, CRM, conversation store, or agent framework still needs to maintain it.

Normalize what happened, not how it happened

One mistake teams make is forcing every channel into the same schema. Email and voice are fundamentally different.

An email event contains sender and recipient addresses, message ID, subject, delivery status, bounce metadata, and thread information.

A voice event contains calling and called numbers, call-control IDs, call-session IDs, call state, duration, and recording references.

Those differences are useful. Keep them.

Instead of making every event identical, define a small set of fields your application can consistently understand.

For example, here is a raw email.received webhook from Telnyx:

{
  "data": {
    "event_type": "email.received",
    "id": "f3a2c1d0-1234-4abc-9def-67890abcdef0",
    "occurred_at": "2026-09-21T11:30:00.000Z",
    "payload": {
      "id": "b0c7e8cb-6227-4c74-9f32-c7f80c30934b",
      "status": "received",
      "occurred_at": "2026-09-21T11:30:00.000Z",
      "from": { "email": "[email protected]" },
      "to": [{ "email": "[email protected]" }],
      "subject": "Where is my order?",
      "inbox_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
    },
    "record_type": "event"
  },
  "meta": {
    "attempt": 1
  }
}

And here is a raw message.received webhook for an inbound SMS:

{
  "data": {
    "event_type": "message.received",
    "id": "b301ed3f-1490-491f-995f-6e64e69674d4",
    "occurred_at": "2026-09-21T11:42:00.000Z",
    "payload": {
      "id": "84cca175-9755-4859-b67f-4730d7f58aa3",
      "from": { "phone_number": "+13125550001" },
      "to": [{ "phone_number": "+17735550002" }],
      "text": "Following up on my email about order #4821",
      "direction": "inbound",
      "messaging_profile_id": "740572b6-099c-44a1-89b9-6c92163bc68d",
      "type": "SMS"
    },
    "record_type": "event"
  },
  "meta": {
    "attempt": 1,
    "delivered_to": "https://yourapp.com/webhooks/telnyx"
  }
}

Both events share the same envelope (data.event_type, data.id, data.occurred_at, data.payload), but the payload contents are channel-specific. Your normalization layer takes both and produces a common internal event:

def normalize_event(webhook_body):
    data = webhook_body["data"]
    event_type = data["event_type"]
    payload = data["payload"]

    # "email.received" -> ("email", "received"); "call.dtmf.received" -> ("call", "dtmf.received")
    channel, action = event_type.split(".", 1)

    # Voice reports direction as "incoming"/"outgoing" on the payload.
    # For email and messaging, only *.received events are inbound; delivery and
    # tracking events (email.delivered, message.finalized) describe messages you sent.
    if channel == "call":
        inbound = payload.get("direction") == "incoming"
    else:
        inbound = action == "received"

    normalized = {
        "event_id": data["id"],
        "event_type": event_type,
        "channel": channel,
        "action": action,
        "direction": "inbound" if inbound else "outbound",
        "occurred_at": data["occurred_at"],
        "provider_message_id": payload.get("id"),
        "provider_payload": payload,  # preserve everything
    }

    # The customer is the sender on inbound events and the recipient on outbound ones
    if channel == "email":
        # Outbound email webhooks are per-recipient: exactly one of to/cc/bcc is present
        party = payload.get("from") if inbound else (
            payload.get("to") or payload.get("cc") or payload.get("bcc"))
        if isinstance(party, list):
            party = party[0] if party else None
        normalized["customer_address"] = (party or {}).get("email")
        normalized["conversation_id"] = payload.get("inbox_id")
    elif channel == "message":
        party = payload.get("from") if inbound else payload.get("to")
        if isinstance(party, list):
            party = party[0] if party else None
        normalized["customer_address"] = (party or {}).get("phone_number")
        normalized["conversation_id"] = payload.get("messaging_profile_id")
    elif channel == "call":
        # Voice payloads carry plain strings; some mid-call events omit direction,
        # so resolve those from the call_session_id you stored at call.initiated
        normalized["customer_address"] = payload.get("from") if inbound else payload.get("to")
        normalized["conversation_id"] = payload.get("call_session_id")
        normalized["provider_message_id"] = payload.get("call_control_id")

    return normalized

inbox_id and messaging_profile_id group everything arriving at one inbox or profile, not one customer's thread. Treat them as a starting point and derive a real conversation ID in your application from the customer plus the email thread or an open ticket.

The email webhook becomes:

{
  "event_id": "f3a2c1d0-1234-4abc-9def-67890abcdef0",
  "event_type": "email.received",
  "channel": "email",
  "action": "received",
  "direction": "inbound",
  "occurred_at": "2026-09-21T11:30:00.000Z",
  "provider_message_id": "b0c7e8cb-6227-4c74-9f32-c7f80c30934b",
  "customer_address": "[email protected]",
  "conversation_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "provider_payload": { ... }
}

The SMS webhook becomes:

{
  "event_id": "b301ed3f-1490-491f-995f-6e64e69674d4",
  "event_type": "message.received",
  "channel": "message",
  "action": "received",
  "direction": "inbound",
  "occurred_at": "2026-09-21T11:42:00.000Z",
  "provider_message_id": "84cca175-9755-4859-b67f-4730d7f58aa3",
  "customer_address": "+13125550001",
  "conversation_id": "740572b6-099c-44a1-89b9-6c92163bc68d",
  "provider_payload": { ... }
}

Your application now has two useful layers of information.

Normalized fields

These answer questions such as: Which customer is this? Which channel generated the event? What happened? When did it happen? Which conversation does it belong to?

Channel-specific payload

This preserves everything else your application might need later. For email, that might mean bounce metadata. For voice, it could mean a call session ID. For WhatsApp, it might include message type or delivery information.

This avoids two extremes.

Too little normalization: every downstream service has to understand every communications API.

Too much normalization: useful channel-specific information gets thrown away.

A good architecture gives you a predictable application-level event while preserving the original payload underneath.

Separate communication events from business events

There is another useful distinction.

A provider tells you email.bounced. That is a communication event. Your application translates it into customer.unreachable_by_email. That is a business event.

Those are not the same thing.

email.bounced
        │
        ▼
Check bounce type (error_evidence.code)
        │
        ▼
Check customer consent and preferences
        │
        ▼
customer.contact_channel_unavailable
        │
        ▼
Try SMS

This extra layer prevents your business workflows from being tightly coupled to individual provider event names. It also makes provider changes easier in the future.

The Telnyx email webhook payload includes error_evidence.code to distinguish bounce subtypes: 30001 for a hard bounce, 30005 for queue expiry, 30003 or 30099 for an administrative bounce. error_evidence.retryable tells you whether resubmitting can succeed; only 30002 (deferred) is retryable. Your business event layer can use these codes to decide whether to retry, switch channels, or suppress the recipient.

Design your webhook endpoint to do less

A webhook endpoint is not your workflow engine.

Its responsibilities should be:

Receive
↓
Authenticate
↓
Validate
↓
Deduplicate
↓
Store or enqueue
↓
Respond

Then your application processes the event asynchronously. There are several reasons for this.

1. Verify that the webhook is authentic

Webhook endpoints are public HTTP endpoints. You should verify the sender before trusting the payload.

Telnyx signs every webhook delivery with an Ed25519 signature carried in the telnyx-signature-ed25519 and telnyx-timestamp request headers. The Node.js, Python, Go, Java, Ruby, and PHP SDKs all provide verification helpers that accept the raw payload and headers and return a parsed event.

# pip install "telnyx[webhooks]"
from telnyx import Telnyx

client = Telnyx()  # reads TELNYX_API_KEY and TELNYX_PUBLIC_KEY from the environment

try:
    event = client.webhooks.unwrap(
        raw_body,  # the exact request body as a string, do not parse first
        headers=request.headers,  # must include telnyx-signature-ed25519 and telnyx-timestamp
    )
except Exception:
    return "Invalid signature", 400

Do not parse and reserialize JSON before verification. The signature is computed over the exact body bytes received.

2. Expect retries

Webhook delivery is not the same as exactly-once processing. If your endpoint fails to acknowledge an event, providers may retry it. Your application therefore needs to be idempotent.

import json

# Deduplicate by event ID
event_id = event.data.id

if event_id in seen_event_ids:
    return "OK", 200  # already processed, acknowledge and exit

seen_event_ids.add(event_id)

# unwrap() returns a typed object; hand the normalizer the plain JSON body
normalized = normalize_event(json.loads(raw_body))
# ... enqueue normalized + raw_body for a worker, then respond

In production, back seen_event_ids with a shared store such as Redis or a unique database constraint, so deduplication survives restarts and works across multiple workers.

3. Respond quickly

Avoid putting slow CRM queries, model inference, database transformations, or external API calls directly into the webhook request. Instead, enqueue the event and process it in a worker.

4. Store the original event

Normalized events are useful for your application. Raw events are useful for debugging. Keeping both lets you answer questions such as: What did the provider actually send? Did our normalization logic fail? Was the event duplicated?

What should your internal event model contain?

There is no universal schema, but a useful starting point is:

FieldPurpose
event_idUnique identifier for deduplication
event_typeWhat happened
channelProvider channel family: email, message (SMS, MMS, WhatsApp), call
customer_addressThe email address or phone number on the event
customer_idYour internal customer identity, looked up from customer_address
conversation_idGroups related interactions
directionInbound or outbound
occurred_atWhen the event happened
provider_message_idLinks back to the original communication
provider_payloadPreserves channel-specific data

You may not need every field. The important part is defining the fields according to the decisions your application actually needs to make.

When you probably don't need this architecture

Not every application needs an event normalization layer. If you only use one communication channel, direct webhook handling may be simpler. If you send notifications but rarely react to customer responses, adding a full event layer may create unnecessary complexity.

It becomes more valuable when you have:

  • multiple communication channels
  • two-way customer conversations
  • cross-channel workflows
  • AI agents choosing communication channels
  • communication events feeding a CRM or customer timeline
  • multiple teams consuming communication data
  • analytics spanning several channels

The architecture should solve complexity, not create it.

How this maps to Telnyx

Telnyx brings email, SMS, WhatsApp, and programmable voice onto the same communications platform. This is the Composition pillar of how Telnyx is built: many primitives, one control plane. One API, one bill, one support team, one compliance boundary. Add a channel without adding a vendor.

Each channel still has its own capabilities and event payloads. That is expected. But developers can build around a common event-oriented pattern.

All Telnyx webhooks share the same envelope (data.event_type, data.id, data.occurred_at, data.payload) and the same Ed25519 signature verification. That means your normalization layer has less work to do: the envelope is already consistent across channels. Retry schedules and timeouts vary by product, so deduplicate on data.id rather than assuming one schedule.

A practical checklist

Before adding another communication channel, ask:

Event ingestion

  • Where will its webhooks arrive?
  • How will signatures be verified?
  • How will duplicate events be handled?
  • What happens if downstream processing fails?

Data model

  • Which fields should be normalized?
  • Which channel-specific fields need to remain available?
  • How do events map to customers?
  • How do events map to conversations?

Workflow

  • Does the channel decide the next action, or does the application?
  • Can a workflow move from one channel to another?
  • Where is customer preference and consent stored?

Observability

  • Can you trace an event from provider to application?
  • Can you see the raw webhook?
  • Can you replay failed processing?
  • Can you understand why a workflow chose a particular channel?

AI

  • What context does the agent receive?
  • Which communication tools can it invoke?
  • Which actions need deterministic rules or human approval?

If you can answer those questions, adding the next channel becomes much less about creating another isolated integration and more about connecting another source of events to an architecture you already understand.

Build around events, not channels

Multi-channel communications does not get hard because sending an email, an SMS, or a WhatsApp message, or placing a call, is complicated. It gets hard when each channel becomes its own isolated workflow.

An event-driven architecture changes that. Channels report what happened. Your application maintains context. Your workflows decide what happens next. Email, SMS, WhatsApp, and voice become tools your application uses when they fit.

Build your event layer on one platform

One API, one bill, one vendor for email, SMS, WhatsApp, and voice. Start building event-driven workflows on Telnyx.

Start building

Share on Social
Deniz Yakışıklı
Deniz Yakisikli

Originally from Turkiye and living in Amsterdam, Deniz is a senior product marketing manager at Telnyx. She has her MBA in marketing management from the University of Amsterdam. Previously, she worked at the Coca-Cola Company, Vodafone, and Philips Health Systems. In her free tim