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:
| Type | How it works | Example platforms |
|---|---|---|
| Dedicated | One webhook URL per platform connection; it only receives that connection’s traffic | WhatsApp, Email (the default) |
| Shared | One webhook URL for all connections of a platform; Outeract routes each payload to the right connection |
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 type | When |
|---|---|
message.inbound | A user sends you a message (including emoji reactions) |
file.inbound | An inbound message carried an attachment (one per file, alongside the File record) |
conversation.created | First contact between a participant set; the conversation itself is an event |
system.message_delivery_failed | A 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.
Pattern 1: Webhook subscription (recommended)#
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:
| Field | Value |
|---|---|
| Name | Inbound messages |
| URL | https://myapp.com/webhooks/outeract |
| Event types | message.inbound |
| Secret | A 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-..." }
]
}datais the event payload, enriched for message events with resolved user/identity IDs (user_idis the human end: the sender for inbound, the recipient for outbound).edgeslists the event’s graph edges so you can follow up with targeted GraphQL queries, e.g. fetch the conversation from thein_conversationtarget.
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| Header | Meaning |
|---|---|
X-Outeract-Signature | sha256= + HMAC-SHA256 hex digest of the raw request body, keyed with your subscription secret |
X-Outeract-Event-Type / X-Outeract-Event-ID | Route and deduplicate without parsing the body |
X-Outeract-Delivery-ID | Unique per delivery attempt record |
X-Outeract-Timestamp / X-Outeract-Event-Created | When the event was created (ISO 8601) |
X-Outeract-Dispatch-Time | When 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:
- First run: call with no
after(or seed a starting point, see below) and process the page. - Save
pageInfo.endCursor. - Next poll: pass the saved cursor as
after. Emptyedgesmeans nothing new. - If
hasNextPageis 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#
- Subscribe to
message.inbound(mutation above) pointing at your endpoint. ngrok works well for local development. - Send a message to your connected platform account from your phone.
- Receive the signed
message.inbounddelivery at your endpoint; verify the signature and reply 200. - Respond by passing the payload’s
user_idstraight back intosendMessage:
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#
- Outbound Webhooks - subscription management, retries, delivery logs
- Webhook Setup - step-by-step subscription walkthrough
- Message Handling - the full message lifecycle
- Events - event types and payloads