Message Handling#
What happens to a message inside Outeract, from the platform webhook that delivers it, through event creation and graph edges, to conversation threading and fan-out to your webhook subscribers.
Once you know this lifecycle, the rest of the API follows from it: every message is an event, every relationship is an edge, and conversations are events with participant edges.
The lifecycle at a glance#
sequenceDiagram
participant P as Platform
participant O as Outeract
participant D as Delivery Worker
participant C as Your Endpoint
P->>O: POST inbound message
O->>O: Verify signature, parse payload
O->>O: Resolve user & identity, match conversation
O->>O: Create event + edges
O->>D: Queue delivery (Pub/Sub)
D->>D: Match webhook subscriptions
D->>C: POST signed payload
C-->>D: 2xx (or retry)Outbound messages (sendMessage) follow the same path from the middle: the event and edges are created first, then the message is pushed out through the platform API, and the resulting message.outbound event fans out to subscribers identically.
Step 1: Inbound webhook#
Each platform connection registers a webhook with the platform (dedicated per connection, or shared across connections; see Inbound Messages). The platform POSTs to:
POST /webhooks/{webhook_id}/{webhook_secret}The secret in the URL plus the platform’s own signature scheme (e.g. HMAC over the body) authenticate the request. The platform integration then parses the raw payload into normalized messages and status updates.
Step 2: Identity resolution#
For each inbound message, Outeract resolves both ends by external ID within the platform connection, creating records on first contact:
- Sender: the human writing to you. If no
UserIdentityexists for that external ID (phone number, Slack ID, …), a newUserand identity are created automatically, with the profile name when the platform provides one. - Recipient: your business/bot account on that connection, created as a system user if missing.
This is why you rarely need to create users manually: the first inbound message materializes them. See Users.
Step 3: Event creation#
The message becomes an immutable message.inbound event in the ledger:
{
"type": "message",
"message": { "text": "Hello!", "role": "user" },
"platform": "whatsapp",
"external_message_id": "wamid.HBgL...",
"delivery_status": { "sent_at": "2026-07-14T10:30:00.000+00:00" }
}Outbound messages get the mirror-image message.outbound event:
{
"type": "message",
"message": { "text": "Hi! How can we help?", "role": "assistant" },
"platform": "whatsapp",
"external_message_id": "wamid.HBgM...",
"delivery_status": {
"sent_at": "2026-07-14T10:31:01.123+00:00",
"delivered_at": "2026-07-14T10:31:02.456+00:00",
"read_at": null,
"failed_at": null
}
}Payload fields:
| Field | Description |
|---|---|
message.text | The message text |
message.role | user (inbound) or assistant (outbound) |
platform | Platform name (whatsapp, telegram, …) |
external_message_id | The platform’s message ID, used for deduplication and receipt matching |
delivery_status | Timestamps: sent_at, delivered_at, read_at, failed_at, plus error / error_type |
reaction | Present when the message is an emoji reaction (emoji, message_id of the reacted-to message) |
template | Present on outbound template sends (name, language, components) |
Messages with attachments also produce a File record and a file.inbound event per attachment.
Step 4: Edges#
The event is linked into the graph with edges (event as source):
| Edge type | Target | Meaning |
|---|---|---|
sent_by | identity | Who sent it (the sender’s platform identity) |
sent_to | identity | Who received it |
attachment | file | An attached file |
in_conversation | event (conversation) | The thread it belongs to |
Note that sent_by/sent_to point at identities, not users. A user may have many identities, and the edge preserves exactly which platform account was involved. The identity’s user relationship gets you to the person.
Conversations themselves are events (type conversation.created) with their own edges:
| Edge type | Source → Target | Meaning |
|---|---|---|
participant | conversation → user | A member of the conversation |
Step 5: Conversation auto-threading#
Conversations are matched by their exact participant set (the users, not identities, so they thread across platforms):
- Derive the user pair from the sender and recipient identities.
- Look for an existing private conversation with exactly that participant set.
- Inbound: if none exists, create one automatically: a
conversation.createdevent withparticipantedges to both users and an auto-generated title. - Link the message with an
in_conversationedge and bump the conversation’supdatedAt.
For outbound sends, if the pair has multiple conversations, auto-linking is skipped (there’s no way to guess the right thread), so pass conversationId to sendMessage explicitly. See Conversations.
Step 6: Delivery receipts and edits#
Platforms report what happens to sent messages. These updates do not create new message events. They mutate the original event’s payload, matched via external_message_id:
sent/delivered/readreceipts fill in the correspondingdelivery_statustimestamps on themessage.outboundevent.- A
failedreceipt setsfailed_at,error, anderror_type, and emits a separatesystem.message_delivery_failedevent so webhook subscribers hear about it. - Message deletions stamp
deleted_aton the original event’s payload. - Reaction removals stamp
reaction.deleted_aton the original reaction event.
So: poll or re-query a message event to see its current delivery state; subscribe to system.message_delivery_failed to be notified of failures.
Step 7: Fan-out to subscribers#
After the event is committed, its ID is published to the event queue. The webhook worker matches the event type against each active subscription’s patterns (message.inbound, message.*, *, …) and POSTs a signed payload to every match, with retries on failure. Details, payload shape, and signature verification are in Inbound Messages and Outbound Webhooks.
Traversing the graph#
Everything above is queryable. Starting from a message event, its edges carry you to the sender, recipient, and conversation in one query:
query {
event(id: "MESSAGE_EVENT_ID") {
id
eventTypeName
payload
edges {
edgeType # sent_by / sent_to / attachment
targetNodeType # identity / file
targetNodeId # resolve: identities(identityIds:) or files(fileIds:)
}
}
}The in_conversation edge’s target is the conversation event. To list everything in that thread, filter the events query by conversation:
query {
events(conversationId: "CONVERSATION_ID", last: 50) {
edges {
node {
id
eventTypeName
payload
createdAt
}
}
pageInfo { startCursor endCursor hasNextPage }
}
}Or go the other way, from a user to their messages:
query {
events(userId: "USER_ID", eventTypes: ["message.inbound", "message.outbound"], last: 20) {
edges { node { id eventTypeName payload createdAt } }
}
}Raw edges are also directly queryable when you need the graph itself:
query {
edges(eventIds: ["MESSAGE_EVENT_ID"]) {
edgeType
targetNodeId
targetNodeType
}
}See also#
- Sending Messages - the outbound half in detail
- Inbound Messages - consuming messages via webhooks or polling
- Events - the event model
- Edges - the relationship model
- Conversations - threading rules