Use Zeus server-to-server messaging to build a customer-hosted chat interface without exposing Zeus credentials to browsers or mobile applications. Your backend submits incoming messages, Zeus processes them asynchronously, and signed webhooks carry real-time conversation updates back to your systems.
Architecture
Your backend is the security and synchronization boundary between the user interface and Zeus.
- Client sends an authenticated message to the customer backend.
- Zeus accepts the message and processes it asynchronously.
- Zeus delivers the updated conversation through a signed webhook.
- The customer publishes persisted updates to the client.
The browser or mobile app communicates only with your backend. Your backend owns the Zeus API key, webhook verification keys, local conversation state, and the WebSocket, Server-Sent Events, or push connection to the client.
This guide focuses on orchestration. See the API guide for the complete request contract and the webhook guide for event fields and signature verification.
Prerequisites and identity
Before sending messages, obtain:
- a Zeus API key for the account;
- an administrator-provisioned agent channel of type
api; - that channel's Zeus-issued
agentChannelId; - a configured webhook with an active Ed25519 signing key; and
- a stable
contactChannelExternalIdfor each end user or chat session.
A message thread is identified by the account-scoped pair
agentChannelId and contactChannelExternalId. Decide whether the external
ID represents a person or a distinct chat session, then reuse that choice for
every API call and local record belonging to the thread.
The new message endpoints accept only an account-owned api agent channel.
Do not accept an arbitrary agentChannelId from a browser; select it from
trusted server-side configuration.
Initialize a conversation
POST /v1/intents is optional. Use it before the first incoming message when
you need account-configured contact or intent attributes, or when Zeus should
create the initial outgoing message.
curl --request POST 'https://api.openfi.tech/v1/intents' \
--header 'Content-Type: application/json' \
--header "x-api-key: ${ZEUS_API_KEY}" \
--data '{
"source": "customer-chat",
"predefinedAgentChannelId": "YOUR_API_AGENT_CHANNEL_ID",
"contact": {
"attributes": {},
"communicationChannels": [
{
"type": "api",
"id": "customer-user-123"
}
]
},
"attributes": {}
}'
Replace the empty attribute objects only with fields configured for your Zeus
account. A successful request returns an intentId. When initialization
creates an outgoing message, Zeus attempts to deliver an
intent.outgoing-message-created webhook.
If seeded attributes and an initial outgoing message are not needed, skip
this step. The first accepted POST /v1/messages creates the missing contact,
api contact channel, and intent with empty attributes and source api. Zeus
processes that incoming message as the answer to the flow's initial question.
Send messages asynchronously
Create one stable idempotency key for each logical user message and persist it with your local message record. The key must contain between 1 and 255 nonblank characters.
curl --request POST 'https://api.openfi.tech/v1/messages' \
--header 'Content-Type: application/json' \
--header "x-api-key: ${ZEUS_API_KEY}" \
--header 'Idempotency-Key: customer-message-018f5f52' \
--data '{
"agentChannelId": "YOUR_API_AGENT_CHANNEL_ID",
"contactChannelExternalId": "customer-user-123",
"content": "I would like to book an appointment."
}'
Message content must contain between 1 and 255 characters and must not be
blank. A successfully accepted request returns 202 Accepted:
{
"status": "accepted",
"requestId": "39703f17-908e-4d67-b44d-b41d2958d21f",
"intentId": "09c84d67-6b6b-47c4-9d9b-d63f74920ec3",
"messageId": "9f1d651f-b297-4562-a001-190a876bdd80"
}
Save all three IDs and map messageId to the optimistic message in your UI.
A 202 means Zeus persisted the incoming message and its processing work. It
does not mean an outgoing message already exists or guarantee that processing
will create one.
Request identity consists of agentChannelId,
contactChannelExternalId, and content. The same idempotency key with the
same values for those fields returns the original acceptance without
creating another message. Reusing the key after changing any of them returns
409 Conflict. Different keys identify different messages; Zeus accepts
them even while earlier messages for the thread are processing.
sendUserMessage(localMessage):
key = loadOrCreateIdempotencyKey(localMessage.id)
body = {
agentChannelId,
contactChannelExternalId,
content: localMessage.content
}
response = POST /v1/messages with key and body
if response is 202:
persist response.requestId, response.intentId, and response.messageId
mark localMessage as accepted
else if response is a timeout or 5xx:
retry with the same key and unchanged body
else if response is 429:
retry after the response's Retry-After value
else:
surface the validation, channel, or idempotency error
Receive and publish updates
Configure your webhook before accepting chat traffic. For every delivery,
verify the signature over the exact raw body, check the timestamp and event
ID, durably deduplicate the event, and return 2xx promptly. Perform message
merging and client publication in your own background worker.
receiveWebhook(rawBody, headers):
require timestamp, signature, key ID, and event ID headers
reject a timestamp outside the configured replay window
publicKey = loadVerificationKey(headers.keyId)
verify Ed25519 signature over headers.timestamp + "." + rawBody
event = parse rawBody as JSON
require event.id equals headers.eventId
in one database transaction:
if event.id is already durably accepted:
do nothing
else:
insert inbox event keyed by event.id
enqueue durable internal processing
return 204
processWebhookEvent(event):
account = accountConfiguredForThisWebhookEndpoint
affectedThreads = empty set
for each message in event.intent.messages:
thread = findOrCreateThread(
account,
message.agentChannelId,
message.contactChannelExternalId
)
upsert message into thread by message.id
keep the copy with the newest updatedAt
add thread to affectedThreads
for each thread in affectedThreads:
sort messages by createdAt, then id
commit local state
publish changes through WebSocket, SSE, or push
All three webhook event types contain the same bounded intent snapshot.
intent.messages contains at most the latest 100 messages for that intent,
not the complete thread. An intent can contain messages from multiple channel
pairs, so use the identifiers on each message to choose its local thread.
Merge the window into your database; never replace a local transcript with
it.
The outgoing-message event does not identify which message triggered the
event, and concurrent activity can push that message outside the latest-100
window before the snapshot is built. The event also contains no requestId
or inReplyToMessageId. Do not correlate one outgoing event to one accepted
POST request.
Reconcile message history
Webhooks are the normal real-time update path. Use GET /v1/messages when a
chat starts, local state is missing or stale, a webhook may have been missed,
or older history is requested.
Request the latest page:
curl --get 'https://api.openfi.tech/v1/messages' \
--header "x-api-key: ${ZEUS_API_KEY}" \
--data-urlencode 'agentChannelId=YOUR_API_AGENT_CHANNEL_ID' \
--data-urlencode 'contactChannelExternalId=customer-user-123'
The endpoint returns up to 100 messages in chronological order and a cursor for older history:
{
"messages": [
{
"id": "6d66d1a0-f2d2-4bb6-942d-c64b9c981a79",
"agentChannelId": "8dc72cb9-eaf2-4b6f-af0d-e68ee6add246",
"contactChannelExternalId": "customer-user-123",
"content": "Hi, how can I help?",
"type": "Outgoing",
"createdAt": "2026-08-03T10:00:00.000Z",
"updatedAt": "2026-08-03T10:00:00.000Z"
}
],
"nextCursor": "6d66d1a0-f2d2-4bb6-942d-c64b9c981a79"
}
Pass nextCursor unchanged to request the next, strictly older page:
curl --get 'https://api.openfi.tech/v1/messages' \
--header "x-api-key: ${ZEUS_API_KEY}" \
--data-urlencode 'agentChannelId=YOUR_API_AGENT_CHANNEL_ID' \
--data-urlencode 'contactChannelExternalId=customer-user-123' \
--data-urlencode 'cursor=6d66d1a0-f2d2-4bb6-942d-c64b9c981a79'
Each page is ordered oldest to newest, but cursors move backward through
history. This is not a "messages since" cursor. Stop when nextCursor is
null or after finding the local message that closes the gap.
reconcileThread(thread, needOlderHistory):
page = GET /v1/messages without cursor
merge page.messages by message.id
while needOlderHistory and page.nextCursor is not null:
page = GET /v1/messages with cursor = page.nextCursor
merge page.messages by message.id
sort local messages by createdAt, then id
publish any changes to the connected client
GET is a reconciliation path, not a replacement for webhooks or a continuous polling feed. It is independently limited to 10,000 requests per account in 24 hours.
Failure handling and ordering
Design for these normal distributed-system outcomes:
- Ambiguous API result: A timeout or
500does not confirm rejection. Retry the same POST body with the same idempotency key. - Concurrent incoming messages: Every valid distinct key is accepted, but processing an older message may intentionally produce no outgoing message after a newer message arrives.
- Human-managed conversation: Zeus may accept and store the incoming message without generating an automated reply.
- Duplicate webhook: Deduplicate using the event ID and return
2xxafter durable acceptance. - Out-of-order webhook: Upsert messages by ID and order them by
createdAt, thenid. Do not treat delivery order as state order. - Missed webhook: Delivery is best effort until an event reaches the Zeus delivery queue. Reconcile the latest messages through GET when local state may be stale.
- Webhook retry: Once queued, a failed event is retried with the same event ID and body, but a new request ID, timestamp, and signature.
A 202 acceptance and an intent.outgoing-message-created webhook are not a
request-and-response pair. Model chat state as a stream of independently
identified messages.
Security checklist
- Keep the Zeus API key in a secret manager and use it only from your backend. Never place it in browser bundles, mobile application code, URLs, or logs.
- Authenticate your user before accepting a chat request and authorize access
to the requested
contactChannelExternalId. - Select
agentChannelIdfrom trusted server-side configuration instead of forwarding an arbitrary browser value. - Use HTTPS for your client API and Zeus webhook endpoint.
- Require signed webhook headers, verify the exact raw body, enforce a replay window, and reject unknown signing key IDs.
- Persist webhook event IDs and API idempotency keys so restarts do not cause duplicate side effects or messages.
- Set request-body limits on your webhook endpoint and acknowledge only after the event and its internal work are durable.
- Escape or sanitize message content when rendering it in a browser.
Responsibilities
| Zeus | Your integration |
|---|---|
| Authenticate the account-scoped API key. | Keep the API key server-side and authenticate your own users. |
| Idempotently persist each accepted incoming message and its asynchronous processing work before returning 202. | Persist one idempotency key per logical message and retry ambiguous results with the same request. |
| Process accepted messages asynchronously; processing may intentionally create no outgoing message. | Treat accepted messages as pending and do not expect one outgoing reply per request. |
| Send bounded intent snapshots through webhooks and retry failed deliveries after successful enqueue. | Verify signatures, durably deduplicate event IDs, tolerate duplicates and reordering, and acknowledge promptly. |
| Provide cursor-paginated message history for api channel threads. | Persist and merge local message state, reconcile when needed, and publish updates to connected clients. |