--- title: "Message Events" description: "The message.received and message.sent events for SMS and WhatsApp: full payload, the reply field, the source vocabulary, delivery timing, and why text costs are indicative." publishedAt: "2026-08-22" modifiedAt: "2026-08-22" category: "Reference" tags: "Voicetta, SMS, WhatsApp, webhook, message.received, message.sent, text messages" navOrder: "32" canonical: "https://voicetta.com/developers/message-events" --- # Message Events Two events cover every SMS and WhatsApp message your assistants handle. They are the live feed: what is being said, right now. | Event | Fires for | |---|---| | `message.received` | A message that arrived from a customer, together with the reply we sent back | | `message.sent` | A message we sent on our own initiative, with no incoming message behind it | Everything about headers, signatures, retries and the shared envelope is on [the webhook reference](/developers/webhooks). This page is the payload and the two or three behaviours specific to text. ## Why received and sent are separate events An arriving message and an outgoing one are not the same thing, and one record is never both. When a customer texts you, we create one record holding their message and the reply our assistant generated in the same turn. That is `message.received`, and the reply travels with it in a named `reply` field rather than as an unlabelled second entry. When we start the conversation — a confirmation the voice assistant sent mid-call, or a recovery follow-up after a conversation went badly — there is nothing incoming to report. That is `message.sent`, and it has no `reply`. Collapsing the two into one event would mean every receiver inspecting a direction field to work out which half of the payload to trust. Two names say it up front. ## `message.received` ```json { "event": "message.received", "event_id": "b71e9d02-4a6c-4f18-9d3e-7c2b5a4e8f31", "version": 1, "occurred_at": "2026-08-20T14:12:00+00:00", "message": { "message_id": "b71e9d02-4a6c-4f18-9d3e-7c2b5a4e8f31", "workspace_id": "2a1f8e7d-4c3b-4a29-9f18-6d5e4c3b2a19", "assistant_id": "7c6b5a49-3e2d-4c1b-8a97-5f4e3d2c1b09", "assistant_name": "Front Desk", "channel": "whatsapp", "direction": "inbound", "thread_id": "c82fa013-5b7d-4029-8e4f-8d3c6b5f9a42", "source": "inbound", "occurred_at": "2026-08-20T14:12:00+00:00", "content": "Do you have a room free on Friday?", "provider_message_id": "SM8f3c1a2b4d5e6f70", "reply": { "content": "Yes, we have a double available. Shall I hold it?", "provider_message_id": "SM1a2b3c4d5e6f7080", "timestamp": "2026-08-20T14:12:04+00:00" }, "customer": { "customer_id": "5d4c3b2a-1f09-4e8d-7c6b-5a493e2d1c0b", "name": "Jane Texter", "phone": "+15551234567" } } } ``` ## `message.sent` ```json { "event": "message.sent", "event_id": "d93ba124-6e8f-4130-9a5f-9e4d7c6b0a53", "version": 1, "occurred_at": "2026-08-20T10:03:12+00:00", "message": { "message_id": "d93ba124-6e8f-4130-9a5f-9e4d7c6b0a53", "workspace_id": "2a1f8e7d-4c3b-4a29-9f18-6d5e4c3b2a19", "assistant_id": "7c6b5a49-3e2d-4c1b-8a97-5f4e3d2c1b09", "assistant_name": "Front Desk", "channel": "sms", "direction": "outbound", "thread_id": "e04cb235-7f90-4241-8b60-af5e8d7c1b64", "source": "voice_tool", "occurred_at": "2026-08-20T10:03:12+00:00", "content": "Your booking reference is BK-4471. See you Friday.", "provider_message_id": "SM2b3c4d5e6f708190", "customer": { "customer_id": "5d4c3b2a-1f09-4e8d-7c6b-5a493e2d1c0b", "phone": "+15551234567" } } } ``` ## Reading the fields - **`content` is always present.** It is the event. A notification that something was said, without what was said, would not be worth delivering. - **`channel`** is `sms` or `whatsapp`. - **`direction`** is `inbound` on received and `outbound` on sent. It is redundant with the event name on purpose, so a stored payload is self-describing without its headers. - **`thread_id`** is the conversation this message belongs to, and the same value the [thread event](/developers/thread-events) reports when that conversation finishes. Group on it to reconstruct a conversation from individual messages. - **`provider_message_id`** is the carrier's own id, for cross-referencing against a Twilio or Telnyx log. It is absent when the provider gave us none. - **`reply`** appears on `message.received` only, and only when the assistant answered in the same turn. A received message with no `reply` means no reply was generated — worth surfacing rather than swallowing. ### `source`: who started it | Value | Meaning | |---|---| | `inbound` | The customer messaged first | | `voice_tool` | The voice assistant sent it during a phone call, typically a confirmation | | `evaluation_follow_up` | A recovery follow-up after a conversation was graded as having a gap | | `test` | Sent from the dashboard's test panel | The first three are real traffic and are all delivered. `test` never is — test records are excluded from webhooks and from the read API alike, so nothing you built for production sees them. The value is documented because it appears in the dashboard, not because you will receive one. Treat an unfamiliar `source` as "some other origin" rather than as an error. New ones get added as the product grows, and that is an addition rather than a breaking change. ## Timing We poll for new messages every 15 seconds, so a message reaches you within roughly 15 to 30 seconds of being sent or received. That is live enough to drive an activity feed and not fast enough to build a chat client on. It is also unordered: deliveries run concurrently, so the second message of an exchange can arrive before the first. Order on `occurred_at`. ## Costs are indicative, not billed Tick the `costs` block and each message carries a `message_cost` and a `currency`. **That number is a rate, applied when the payload is built.** Unlike the `charged` amount on a voice call, text messaging is not debited from your wallet per message, so nothing here reconciles against an invoice. It is the right figure for showing a client roughly what their messaging is worth, and the wrong figure for anything you bill on. Your invoice is the source of truth for billing. ## What a message payload does not carry No summary, no evaluation verdict, no recording, no latency measurement and no configuration snapshot. All of those describe a finished conversation or an audio pipeline, and a single message is neither. Those blocks are **absent** from the payload rather than sent as nulls or empty arrays. If you want the graded view of the conversation, that is the [thread event](/developers/thread-events) — the two are complements, joined by `thread_id`. ## Reading messages instead of receiving them `GET /v1/messages` returns the same objects on demand, with filters for date, assistant, channel, direction and `thread_id`. Full parameters in [the read API reference](/developers/api-reference#get-v1-messages). This is also how you get messages from before you ticked the event, since enabling an event is not retroactive. ## Related pages - [Webhook reference](/developers/webhooks) — headers, signature verification, and retries - [Thread events](/developers/thread-events) — the graded record of the same conversation - [Read API reference](/developers/api-reference) — `/v1/messages` parameters and paging - [Quickstart](/developers/quickstart) — see a real payload before writing code - [Overview](/developers/overview) — how the collections fit together