Omnichannel Inbox & ACD
SIP.IO’s CCaaS differentiator: a messaging conversation routes to an agent through the same distribution brain as a voice call, the per-account PresenceEngine. An agent works one inbox for calls and chats, not two separate tools. The mechanics differ where they have to, chat is concurrent (an agent can hold several conversations at once) where voice is one-call-at-a-time, but login state, availability, queues, and skill tiers are all shared.
Chat capacity
Section titled “Chat capacity”An agent’s concurrent-chat capacity is a single number, max_chats, on the same agent row the voice ACD uses. 0 (the default) means voice-only: the agent never receives a conversation. Setting it above zero opts the agent into chat routing, up to that many conversations at once.
An agent is chat-ready when: available and reachable and currently holding fewer than max_chats conversations. That’s deliberately the same shape as voice readiness (available + reachable + not busy), just with a counted capacity instead of a boolean.
Routing: routeConversation
Section titled “Routing: routeConversation”When an inbound message creates or reopens a conversation on a channel that has a default_queue_id, the messaging worker calls into the account’s PresenceEngine to route it:
- If the conversation is already assigned (e.g. a second inbound message on an already-open thread), routing is a no-op, idempotent.
- Otherwise it looks for a chat-ready agent on that queue:
available,reachable,max_chats > 0, and under capacity, preferring the agent with the fewest active chats (load-balances across the team rather than piling onto one agent). - Found:
conversation.assigned_agent_idis set, status becomesassigned. - Not found: the conversation is enqueued (FIFO) and waits for an agent to free up.
Routing is best-effort: if it fails for any reason, the message is still stored and the conversation stays open, unassigned, so nothing is lost, worst case a conversation needs to be manually picked up or waits for the next drain.
Draining the backlog
Section titled “Draining the backlog”A waiting conversation doesn’t just sit until someone happens to close a chat, it drains on two triggers:
- Close-drain: when an agent closes a conversation (
POST /conversations/{id}/close), their freed slot immediately pulls the next FIFO-waiting conversation on that queue onto them, if one exists. - Login-drain: when an agent transitions to
available(login, coming off pause, going available), their now-open chat slots are filled from the backlog of their queues, FIFO, before anything else happens. This closes the gap that close-drain alone leaves: previously, a backlog could sit idle if agents were mid-call or offline when messages queued up and nobody happened to close a chat afterward. Login-drain runs alongside the existing voice dispatch on the sameavailable/login/unpausetransition, so a chat-capable agent coming online gets pushed both a waiting call and a backlog of waiting conversations in the same step.
Each drained assignment is reflected back to the edge SQL database (conversation.assigned_agent_id, status='assigned') so the agent inbox and any observers see it immediately.
The agent inbox (/v1/conversations)
Section titled “The agent inbox (/v1/conversations)”The read/reply API that makes the omnichannel ACD usable day to day. Scope messaging.
List (the inbox itself):
curl 'https://api.sip.io/v1/conversations?status=assigned&agent=me' \ -H 'x-api-key: sk_…'GET /v1/conversations accepts status (open/assigned/closed/snoozed), agent (me resolves to the caller’s own user id, or pass an explicit user id), channel, queue, plus keyset pagination via cursor/limit (max 100, default 30, ordered by most-recent activity). Returns each conversation’s header: id, channel, contact_address, contact_name, status, assigned_agent_id, queue_id, last_message_at, last_inbound_at, unread.
Thread:
curl https://api.sip.io/v1/conversations/conv_… -H 'x-api-key: sk_…'GET /v1/conversations/{id} returns the conversation header plus its message thread (most-recent-first, keyset-paginated, max 200 per page, default 50). Opening a thread this way clears its unread badge as a side effect, matching an agent app’s expectation that viewing a conversation marks it read.
Reply:
curl -X POST https://api.sip.io/v1/conversations/conv_…/reply \ -H 'x-api-key: sk_…' -H 'content-type: application/json' \ -d '{ "body": "On it, one moment" }'POST /v1/conversations/{id}/reply accepts { body | templateName | mediaUrls, templateLang, templateVars }. The channel and recipient are derived from the conversation itself, an agent doesn’t need to know or pass the channel id or contact address. The same opt-out and WhatsApp 24-hour-window rules from sending apply. Replying to a closed conversation reopens it.
Close:
curl -X POST https://messaging.sip.io/conversations/conv_…/close -H 'x-api-key: sk_…'POST /conversations/{id}/close (on the messaging worker; not yet exposed on the /v1 facade) marks the conversation closed and frees the agent’s chat slot, triggering close-drain. The response includes reassigned if a waiting conversation was immediately handed to the newly-freed agent.
Webhooks
Section titled “Webhooks”Messaging events flow through the same account-scoped webhook subscriptions as voice (webhook_subscription, HMAC-signed, see Webhooks). Subscribe to message.received and/or message.status (or * for everything).
| Event | Fires when | data |
|---|---|---|
message.received | An inbound message is stored. | messageId, conversationId, channel, from, to, body, providerMessageId, ts |
message.status | A provider delivery/read/failed callback is correlated to one of our messages (matched by provider_message_id). | messageId, conversationId, providerMessageId, status, channel, ts |
Payload shape matches the voice webhooks exactly:
{ "event": "message.received", "account_id": "acc_acme", "data": { "messageId": "msg_…", "conversationId": "conv_…", "channel": "sms", "from": "+14155550100", "to": "+14155550199", "body": "Hi, is my order ready?", "providerMessageId": "SMxxxx", "ts": 1719600000000 }, "ts": 1719600000000}Delivery is fired via waitUntil off the provider’s 200 acknowledgment path, so a slow or failing webhook endpoint never delays or blocks accepting the inbound message. Signing, delivery semantics (5-second timeout per attempt, automatic retry on transient failures, x-sipio-signature: sha256=<hex>), and subscription management are identical to the voice-side webhooks, see Webhooks for the subscribe/verify mechanics.
A channel’s own SMS-to-webhook forward is a separate, per-channel mechanism using the same envelope and signature scheme, not a substitute for an account-wide message.received subscription; use forwarding for a single number’s dedicated endpoint, and account webhooks for a general integration that should see all channels.
Roadmap
Section titled “Roadmap”Realtime push to the agent inbox (today it’s poll/pull via the list and thread endpoints, no WebSocket push yet), a blending policy for agents who take both voice and chat (today they’re independent capacity dimensions with no priority between them), and outbound message.sent/status-change webhooks on the send path itself (today message.status only fires from provider delivery callbacks).