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 UserIdentity exists for that external ID (phone number, Slack ID, …), a new User and 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:

FieldDescription
message.textThe message text
message.roleuser (inbound) or assistant (outbound)
platformPlatform name (whatsapp, telegram, …)
external_message_idThe platform’s message ID, used for deduplication and receipt matching
delivery_statusTimestamps: sent_at, delivered_at, read_at, failed_at, plus error / error_type
reactionPresent when the message is an emoji reaction (emoji, message_id of the reacted-to message)
templatePresent 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 typeTargetMeaning
sent_byidentityWho sent it (the sender’s platform identity)
sent_toidentityWho received it
attachmentfileAn attached file
in_conversationevent (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 typeSource → TargetMeaning
participantconversation → userA 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):

  1. Derive the user pair from the sender and recipient identities.
  2. Look for an existing private conversation with exactly that participant set.
  3. Inbound: if none exists, create one automatically: a conversation.created event with participant edges to both users and an auto-generated title.
  4. Link the message with an in_conversation edge and bump the conversation’s updatedAt.

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 / read receipts fill in the corresponding delivery_status timestamps on the message.outbound event.
  • A failed receipt sets failed_at, error, and error_type, and emits a separate system.message_delivery_failed event so webhook subscribers hear about it.
  • Message deletions stamp deleted_at on the original event’s payload.
  • Reaction removals stamp reaction.deleted_at on 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#