Handling Inbound Messages#

How messages get into Outeract from the platforms, what events they produce, and the two ways to consume them: webhook subscriptions (recommended) or polling the events query.

For what happens between arrival and fan-out (identity resolution, edges, conversation threading), see Message Handling.

How platform webhooks deliver into Outeract#

When you connect a platform, Outeract provisions an inbound webhook and registers it with the platform. Incoming traffic arrives at:

POST /webhooks/{webhook_id}/{webhook_secret}

Requests are authenticated by the secret in the URL plus the platform’s own signature scheme (HMAC validation against the platform’s app secret). There are two webhook architectures, chosen per platform:

TypeHow it worksExample platforms
DedicatedOne webhook URL per platform connection; it only receives that connection’s trafficWhatsApp, Email (the default)
SharedOne webhook URL for all connections of a platform; Outeract routes each payload to the right connectionInstagram

You normally never touch this; it’s set up automatically with the connection. If a platform loses its registration, re-register it with the Reconnect Webhook action on the connection in the console.

What events fire#

Each parsed inbound payload produces events in the ledger:

Event typeWhen
message.inboundA user sends you a message (including emoji reactions)
file.inboundAn inbound message carried an attachment (one per file, alongside the File record)
conversation.createdFirst contact between a participant set; the conversation itself is an event
system.message_delivery_failedA message you sent failed to deliver (e.g. messaging window expired)

Delivery receipts (sent/delivered/read) for your outbound messages do not create events. They update the original message.outbound event’s delivery_status in place.

Subscribe once and Outeract pushes matching events to your endpoint the moment they’re committed, with no polling, the lowest latency, and retries built in.

Create a subscription in the console under Settings → Webhooks:

FieldValue
NameInbound messages
URLhttps://myapp.com/webhooks/outeract
Event typesmessage.inbound
SecretA long random signing secret (or leave blank to have one generated)

The event type list accepts glob patterns: ["message.*"] for all message traffic, ["message.inbound", "system.message_delivery_failed"] to hear about replies and failures, ["*"] for everything. Full subscription management (headers, retry limits, timeouts, delivery logs) is covered in Outbound Webhooks and Webhook Setup.

Delivery payload#

Each matching event is POSTed to your URL as JSON:

{
  "event_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "event_type": "message.inbound",
  "timestamp": "2026-07-14T10:30:00.000000+00:00",
  "app_id": "0b1f9a2e-1111-2222-3333-444455556666",
  "data": {
    "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" },
    "user_id": "9f8e7d6c-...",
    "from_user_id": "9f8e7d6c-...",
    "from_identity_id": "5a4b3c2d-...",
    "to_user_id": "1a2b3c4d-...",
    "to_identity_id": "6b5c4d3e-..."
  },
  "edges": [
    { "edge_type": "sent_by", "target_node_type": "identity", "target_node_id": "5a4b3c2d-..." },
    { "edge_type": "sent_to", "target_node_type": "identity", "target_node_id": "6b5c4d3e-..." },
    { "edge_type": "in_conversation", "target_node_type": "event", "target_node_id": "c0ffee00-..." }
  ]
}
  • data is the event payload, enriched for message events with resolved user/identity IDs (user_id is the human end: the sender for inbound, the recipient for outbound).
  • edges lists the event’s graph edges so you can follow up with targeted GraphQL queries, e.g. fetch the conversation from the in_conversation target.

Delivery headers#

Content-Type: application/json
X-Outeract-Signature: sha256=8b1a9953c4611296a827abf8c47804d7...
X-Outeract-Event-Type: message.inbound
X-Outeract-Event-ID: 7c9e6679-7425-40de-944b-e07fc1f90ae7
X-Outeract-Delivery-ID: 3d2c1b0a-...
X-Outeract-Timestamp: 2026-07-14T10:30:00.000000+00:00
X-Outeract-Dispatch-Time: 1784197801123
X-Outeract-Event-Created: 2026-07-14T10:30:00.000000+00:00
HeaderMeaning
X-Outeract-Signaturesha256= + HMAC-SHA256 hex digest of the raw request body, keyed with your subscription secret
X-Outeract-Event-Type / X-Outeract-Event-IDRoute and deduplicate without parsing the body
X-Outeract-Delivery-IDUnique per delivery attempt record
X-Outeract-Timestamp / X-Outeract-Event-CreatedWhen the event was created (ISO 8601)
X-Outeract-Dispatch-TimeWhen this delivery was dispatched (ms since epoch); useful for latency measurement

Verifying the signature#

Compute HMAC-SHA256 over the raw body bytes with your subscription secret and compare against X-Outeract-Signature using a constant-time comparison. Always verify against the raw body, and don’t re-serialize the parsed JSON.

import hashlib
import hmac

def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)

# e.g. with Starlette / FastAPI
# @app.post("/webhooks/outeract")
# async def handler(request: Request):
#     body = await request.body()
#     if not verify_signature(body, request.headers["X-Outeract-Signature"], SECRET):
#         return Response(status_code=401)
#     event = json.loads(body)
#     if event["event_type"] == "message.inbound":
#         handle_message(event["data"])
#     return Response(status_code=200)
const crypto = require('crypto');

function verifySignature(rawBody, signatureHeader, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// e.g. with Express: keep the raw body for verification
// app.post('/webhooks/outeract',
//   express.raw({ type: 'application/json' }),
//   (req, res) => {
//     if (!verifySignature(req.body, req.get('X-Outeract-Signature'), SECRET)) {
//       return res.sendStatus(401);
//     }
//     const event = JSON.parse(req.body);
//     if (event.event_type === 'message.inbound') handleMessage(event.data);
//     res.sendStatus(200);
//   });

Respond with a 2xx status within your subscription’s timeout. Non-2xx responses and timeouts are retried up to the subscription’s maxAttempts with backoff; use event_id (or X-Outeract-Event-ID) as your idempotency key, since retries and at-least-once queueing can deliver the same event more than once.

Verify the whole setup end-to-end with a test delivery. The subscription’s test action in the console sends a webhook.test event to your endpoint and reports the HTTP status and response body it got back.

Pattern 2: Polling the events query#

When you can’t expose an HTTP endpoint (local scripts, batch jobs, restricted networks), poll the events query with cursor pagination. Results are chronological (oldest first) and pageInfo.endCursor marks the newest event in the page, so persist it between polls:

query Poll($after: String) {
  events(first: 100, after: $after, eventTypes: ["message.inbound"]) {
    edges {
      node {
        id
        eventTypeName
        payload
        createdAt
        edges {
          edgeType
          targetNodeType
          targetNodeId
        }
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

The loop:

  1. First run: call with no after (or seed a starting point, see below) and process the page.
  2. Save pageInfo.endCursor.
  3. Next poll: pass the saved cursor as after. Empty edges means nothing new.
  4. If hasNextPage is true, keep paging before sleeping.

To seed the cursor from a known position instead of paging history:

# From the last event ID you processed
query { eventCursor(eventId: "LAST_EVENT_ID") }

# Or from a point in time
query { eventCursorAtDatetime(datetime: "2026-07-14T00:00:00Z") }

Alternatively pass since: "2026-07-14T00:00:00Z" directly to events for a one-shot time-based fetch.

Polling trades latency for simplicity: you'll see messages only as often as you poll, and you own cursor persistence and deduplication. For anything user-facing or real-time, use a webhook subscription.

End-to-end example#

  1. Subscribe to message.inbound (mutation above) pointing at your endpoint. ngrok works well for local development.
  2. Send a message to your connected platform account from your phone.
  3. Receive the signed message.inbound delivery at your endpoint; verify the signature and reply 200.
  4. Respond by passing the payload’s user_id straight back into sendMessage:
mutation {
  sendMessage(
    recipientUserId: "USER_ID_FROM_WEBHOOK"
    message: "Thanks, we got your message!"
  ) {
    id
  }
}

That round trip, inbound webhook in and sendMessage out, is the core of every bot and support integration built on Outeract.

See also#