Skip to main content

The Web Chat delivery model

Web Chat is Devotel Orbit’s owned website widget channel: a visitor chats on your site, and your operators or AI agents reply from the Orbit inbox. It sits alongside SMS, WhatsApp, and email in the Messages hub, but its delivery contract is fundamentally different. Nothing leaves your tenant. There is no carrier, no third-party messaging API, no egress hop at all. This page explains the loopback contract behind that: what “delivered” means on an owned channel, how the persist-then-poll loop works, and how to troubleshoot a reply that never renders. For the widget’s trust and guardrail model, see widget flow model; this page covers the delivery contract.

Section 1 — Why Web Chat is different: delivery is persistence

Every carrier channel ends in someone else’s network: an SMS exits through a gateway, a WhatsApp message through Meta, an email through a provider, each with its own receipts, latency, and failure modes. Web Chat has no counterpart. The widget runs on your site, and the Orbit API is the only other participant in the exchange, so there is no downstream network that could accept, queue, or drop a message. On Web Chat, “delivered” therefore means persisted. A reply is delivered the moment its row is durably written to your tenant’s messages table, because that row is precisely what the visitor’s widget reads. The persistence the send pipeline performs is not a prerequisite for delivery; it is the delivery. Two consequences follow:
  • Zero per-message carrier cost. The web_chat channel rate is zero; no egress leg is priced on this channel.
  • No carrier receipts. Status does not arrive asynchronously from an external network. Where SMS waits on delivery receipts (see message status DAG), a Web Chat row’s terminal status is set the moment the send is accepted.

Section 2 — The loopback contract: accept, persist, fan out, poll

An operator or AI agent reply runs through four steps, all inside Orbit:
  1. Persist. The send pipeline inserts the outbound row into your tenant’s messages table with status queued and the conversation reference in its metadata.
  2. Accept. The pipeline dispatches the send to the Web Chat provider through the message router, like any other channel. The provider validates the payload (a send with neither body nor media attachment is rejected with 422) and accepts it. There is no external API call to make; acceptance is the entirety of the provider’s network behavior.
  3. Flip and fan out. The pipeline writes the provider’s returned status, delivered, back to the row and publishes the change to the inbox’s live event stream, so an operator watching the conversation sees the reply land immediately.
  4. Poll. The visitor’s widget calls GET /widget/conversations/:id/messages on its interval and renders the reply from the persisted rows.
Acceptance and delivery coincide on this channel: because no external hop remains after step 2, nothing can fail between “accepted” and “delivered.” That is the loopback. The message exits the send pipeline and arrives at a read path Orbit also owns.

Section 3 — Why registration matters: the 503 that motivated the provider

Loopback does not mean “no routing.” Sends still pass through the shared message router, and the router refuses to dispatch to a channel with no registered provider; that check is hasChannel("web_chat"). Before the loopback provider was registered, the check failed, so every operator and AI agent reply returned 503 CHANNEL_NOT_CONFIGURED, and because the failure aborted the send before its persist-then-flip sequence completed, the reply never reached the messages table. The widget’s poll could never have surfaced it. The visitor side kept working (visitor messages into your tenant flow through the standard inbound pipeline), so the failure mode was the worst kind: conversations that looked alive on one side and went silent on the other. The resolution was structural rather than operational. Registering the loopback provider makes hasChannel("web_chat") true on every deployment, and the established persist-then-poll path runs. There is nothing to configure on your side: Web Chat requires no provider credentials, no sender registration, and no connection step, and the channel registry records it as generally available with no external provider required.

Section 4 — Two read paths: widget poll vs the operator’s live stream

The same persisted rows serve two readers over different transports:
  • Visitor side, polling. The widget pulls GET /widget/conversations/:id/messages, authenticated by the visitor session token your embed received, sent as Authorization: Bearer <token>. The token must own the conversation: a token that belongs to a different visitor gets a 404 for the conversation, and an invalid or expired token gets a 401.
  • Operator side, live stream. The inbox subscribes to the event fan-out from step 3 over server-sent events, so an active operator sees visitor messages and their own replies with no polling lag.
When each wins: the live stream wins on latency for a human actively working a conversation. Polling wins on resilience, because the truth is the table, a missed event never loses a message, and the next poll reconciles. That is exactly why the visitor side does not depend on a push transport: a backgrounded tab, an aggressive proxy, or a dropped socket costs at most one poll interval, never a message.

Section 5 — How the loopback compares: Web Chat vs SMS vs in-app

The closest relative is the in-app channel: both are owned, zero-carrier, pull-rendered surfaces. The difference is shape. In-app is a content feed your SDK renders (see in-app channel model); Web Chat is a conversational thread with a per-conversation poll and a live inbox on the operator side.

Section 6 — Message row states on Web Chat

An outbound Web Chat row moves through a deliberately short path:
  • queued: inserted by the send pipeline, before provider acceptance.
  • delivered: written synchronously when the provider accepts. There is no in-transit interval on this channel, because there is no external network to be in transit through.
Read delivered on web_chat as “persisted and readable by the widget’s next poll,” not as “a human saw it.” Whether the visitor’s widget has rendered the row is a function of the poll cadence, not of the status. The shortened path is specific to owned channels; the full vocabulary, including the carrier receipt states this channel skips, lives on the message status DAG page.

Worked example — send a reply, then poll for it

Send an operator reply into a conversation with the unified send endpoint; there is no per-channel path for Web Chat, so leave channel as web_chat and set to to the conversation id the widget received when it started the conversation (POST /widget/conversations returns it alongside channel: "web_chat"). At dispatch the router checks hasChannel("web_chat"), accepts, and the pipeline persists and flips the row as Section 2 describes:
The visitor’s widget picks the reply up on its next poll:
The response lists the conversation’s rows oldest first (up to 100 per poll), each with its body, direction, media URL, status, and creation time. The reply appears as soon as it is persisted, which on this channel is the same moment the send was accepted.

Section 7 — Troubleshooting: a reply that never appears

Work through the loop in order.
  1. The send errored before persistence. A 422 means the payload carried neither body nor media attachment. A 503 with CHANNEL_NOT_CONFIGURED means the router has no provider registered for the channel; on a current deployment Web Chat ships registered, so that error points at a stale API worker rather than at anything on your side. Either way, no row was written and there is nothing for the poll to read.
  2. The poll is healthy but has not run since. The widget polls on an interval, and backgrounded browser tabs throttle timers. A reply persisted between polls appears on the next poll; factor the interval into any “it did not show for a minute” report before suspecting the pipeline.
  3. Token or conversation mismatch. The poll authenticates with the session token minted for that conversation’s visitor. A token that belongs to a different visitor gets a 404 for the conversation, and an expired token a 401. If your embed re-mints session tokens, make sure the polling client holds the same token that started the conversation.
  4. Reading the wrong surface. The operator inbox renders from the live stream and the widget renders from the poll. A reply visible in the inbox but not in the widget is a poll-path question (steps 2 and 3). A reply visible in neither was never persisted, which returns you to step 1.

Where to go next

  • Widget flow model: the trust model, guardrails, and visitor-side flow this delivery contract serves.
  • In-app channel model: the other owned, zero-carrier surface, and when a rendered feed fits better than a thread.
  • Message status DAG: the full status vocabulary, including the carrier receipt states Web Chat skips.
  • The loopback provider, packages/messaging/src/channels/web_chat/web-chat-provider.ts, is the source of truth for the accept-then-deliver contract described here.