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.
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.
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:

The key design decision is the middle layer: how you transform raw channel events into something your application can reason about.
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:
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.
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.
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.
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?
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.
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.
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.
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.
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.
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.
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?
There is no universal schema, but a useful starting point is:
| Field | Purpose |
|---|---|
event_id | Unique identifier for deduplication |
event_type | What happened |
channel | Provider channel family: email, message (SMS, MMS, WhatsApp), call |
customer_address | The email address or phone number on the event |
customer_id | Your internal customer identity, looked up from customer_address |
conversation_id | Groups related interactions |
direction | Inbound or outbound |
occurred_at | When the event happened |
provider_message_id | Links back to the original communication |
provider_payload | Preserves 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.
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:
The architecture should solve complexity, not create it.
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.
Before adding another communication channel, ask:
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.
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.
One API, one bill, one vendor for email, SMS, WhatsApp, and voice. Start building event-driven workflows on Telnyx.
