Flows
Website Chat WebSocket Flow
Real-time chat over SignalR/WebSocket.
Website Chat — WebSocket (SignalR) Flow
Feature area: Social Messaging / Website Chat
Supersedes: poll-based delivery described inwebsite-chat-widget-flow.md
Last updated: 2026-06-12
Overview
The website chatbot was migrated from HTTP polling to SignalR WebSocket. Messages are now pushed in real-time in both directions — from the customer widget to the store CRM, and from the store/AI back to the widget — with no polling required.
The REST endpoint for sending messages (POST /api/v1/widget/chat/{token}/messages) is kept as a fallback. The polling endpoint (GET /api/v1/widget/chat/{token}/messages) has been removed.
Hubs
| Hub class | Route | Auth | Group format |
|---|---|---|---|
WebsiteChatHub | /hubs/widget-chat | Anonymous | widget:{sessionId} |
StoreConversationHub | /hubs/store-conversations | JWT required | store:{storeId} |
Both hubs live in Sendy.Infrastructure/Hubs/.
Real-Time Push Matrix
| Trigger | Widget receives | Store receives |
|---|---|---|
| Customer sends message (via hub) | "MessageReceived" (echo) | "NewCustomerMessage" |
| Store agent sends reply (REST) | "MessageReceived" | — |
| AI generates reply (Hangfire) | "MessageReceived" | "NewCustomerMessage" |
Flow 1 — Widget Connects and Joins Session
The customer widget connects once on page load and joins its session group.
Widget (browser) WebsiteChatHub
│ │
├─── connect to /hubs/widget-chat ────────────►│
│ │
├─── JoinSession(channelToken, sessionId) ────►│
│ ├── AnyAsync(ExternalAccountId == channelToken,
│ │ ChannelType == Website, IsEnabled)
│ ├── Groups.AddToGroupAsync("widget:{sessionId}")
│◄─── (no return value, connection ready) ─────┤
Hub method: JoinSession(string channelToken, string sessionId)
Group joined: widget:{sessionId}
Throws HubException if the channel token is not found or the channel is disabled.
Flow 2 — Customer Sends a Message
Widget WebsiteChatHub DB StoreConversationHub
│ │ │ │
├── SendMessage(token, │ │ │
│ sessionId, text, │ │ │
│ displayName?) ───────────►│ │ │
│ ├── IWebsiteChatService │ │
│ │ .SendMessageAsync() │ │
│ │ ─────────────────────►│ │
│ │ upsert Conversation │ │
│ │ save Inbound message │ │
│ │ queue AiMessageJob │ │
│ │◄────────────────────────│ │
│ │ │ │
│◄── "MessageReceived" (echo) ──┤ │ │
│ { inbound message } │ │ │
│ ├── query channel.StoreId │ │
│ ├── query conversation.Id │ │
│ │ │ │
│ ├── IWebsiteChatNotifier │ │
│ │ .NotifyStoreAsync() ──────────────────────────►│
│ │ │ "NewCustomerMessage" │
│ │ │ pushed to store group│
Hub method: SendMessage(string channelToken, string sessionId, string text, string? contactDisplayName)
Widget receives: "MessageReceived" with the echoed inbound WidgetMessageResponse
Store receives: "NewCustomerMessage" with StoreNewMessageNotification
Flow 3 — AI Agent Replies (Hangfire)
AiMessageDispatchJob runs every minute and processes queued AiMessageJob records.
AiMessageDispatchJob DB Widget Store CRM
│ │ │ │
├── load pending jobs │ │ │
├── fetch conversation ─────►│ │ │
├── build system prompt │ │ │
├── call AI provider │ │ │
│ │ │ │
├── SocialMessages.Add() ───►│ │ │
│ (Outbound, IsAiGenerated)│ │ │
├── SaveChangesAsync() ──────►│ │ │
│ │ │ │
├── NotifyAsync(sessionId) ──────────────────────►│ │
│ "MessageReceived"│ │
│ │ │
├── NotifyStoreAsync(storeId) ──────────────────────────────────────►│
│ "NewCustomerMessage" │
│ │ │ │
├── RecordUsageAsync(tokens) │ │ │
The push fires after SaveChangesAsync so generatedMessage.Id and CreatedAt are populated. Both pushes are wrapped in a single try/catch — a missed push does not affect the persisted message.
Flow 4 — Store Agent Replies Manually
The store agent uses the REST endpoint (POST /api/v1/store/social/conversations/{id}/messages). After saving, SocialConversationService pushes the reply to the widget.
Store agent (REST) SocialConversationService DB Widget
│ │ │ │
├── POST /conversations/ │ │ │
│ {id}/messages ─────────────►│ │ │
│ ├── load conversation │ │
│ ├── SocialMessages.Add()►│ │
│ ├── SaveChangesAsync() ──►│ │
│ │ │ │
│ ├── NotifyAsync(sessionId) ─────────────►│
│◄── 200 SocialMessageResponse ─┤ "MessageReceived"│
Widget receives: "MessageReceived" with the store agent's WidgetMessageResponse
The store agent's own CRM view updates synchronously via the HTTP response — no hub push needed for the sender.
Flow 5 — Store Agent Connects to CRM Hub
Store agents connect once when the CRM dashboard loads.
Store CRM (browser) StoreConversationHub
│ │
├─── connect to /hubs/store-conversations │
│ (Bearer JWT in header/query) ───────►│ [Authorize] → validates JWT
│ │
├─── JoinStore() ───────────────────────►│
│ ├── reads "store_id" claim from Context.User
│ ├── Groups.AddToGroupAsync("store:{storeId}")
│◄─── (ready to receive pushes) ─────────┤
Hub method: JoinStore() — reads store_id from the JWT claim; no parameter needed.
Group joined: store:{storeId:D}
Throws HubException if the JWT does not contain a valid store_id claim.
Complete Diagram
Store CRM Sendy API Customer Widget
│ │ │
├── connect /hubs/store-conv─►│ │
├── JoinStore() ─────────────►│ (joined "store:{id}" group) │
│ │ │
│ │◄── connect /hubs/widget-chat ──┤
│ │◄── JoinSession(token, sid) ────┤
│ │ (joined "widget:{sid}" grp) │
│ │ │
│ │◄── SendMessage(token,sid,text)─┤
│ │ save Inbound + queue AI job │
│ │── "MessageReceived" ──────────►│ (echo)
│◄── "NewCustomerMessage" ───┤ │
│ │ │
│ [Hangfire — every minute] │
│ │ │
│ ├── AI call → save Outbound │
│ │── "MessageReceived" ──────────►│ (AI reply)
│◄── "NewCustomerMessage" ───┤ │
│ │ │
├── POST /conversations/{id}/messages │
│ ├── save Outbound │
│◄── 200 ───────────────────┤ │
│ │── "MessageReceived" ──────────►│ (store reply)
SignalR Event Payloads
"MessageReceived" — sent to widget group
{
"id": "guid",
"direction": "Inbound | Outbound",
"contentText": "Hello!",
"isAiGenerated": false,
"createdAt": "2026-06-12T10:05:00Z"
}
"NewCustomerMessage" — sent to store group
{
"conversationId": "guid",
"channelId": "guid",
"contactDisplayName": "Ahmed",
"externalContactId": "visitor-session-uuid",
"messageId": "guid",
"contentText": "Hello, I have a question",
"createdAt": "2026-06-12T10:05:00Z"
}
Client Usage — Widget (JavaScript)
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/widget-chat")
.withAutomaticReconnect()
.build();
connection.on("MessageReceived", message => {
renderMessage(message); // { id, direction, contentText, isAiGenerated, createdAt }
});
await connection.start();
await connection.invoke("JoinSession", channelToken, sessionId);
// Send a message
await connection.invoke("SendMessage", channelToken, sessionId, "Hello!", null);
sessionId should be a stable per-visitor UUID stored in localStorage.
channelToken comes from the data-channel attribute on the embed script tag.
Client Usage — Store CRM (JavaScript)
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/store-conversations", {
accessTokenFactory: () => localStorage.getItem("jwt")
})
.withAutomaticReconnect()
.build();
connection.on("NewCustomerMessage", notification => {
// { conversationId, channelId, contactDisplayName, externalContactId,
// messageId, contentText, createdAt }
updateInboxItem(notification);
});
await connection.start();
await connection.invoke("JoinStore"); // storeId is read from the JWT on the server
Source References
| Layer | File |
|---|---|
| Widget hub | Sendy.Infrastructure/Hubs/WebsiteChatHub.cs |
| Store CRM hub | Sendy.Infrastructure/Hubs/StoreConversationHub.cs |
| Notifier interface | Sendy.Application/Interfaces/Services/Messaging/IWebsiteChatNotifier.cs |
| Notifier implementation | Sendy.Infrastructure/Services/Messaging/SignalRWebsiteChatNotifier.cs |
| Widget notification DTO | Sendy.Application/DTOs/Responses/Messaging/WidgetMessageResponse.cs |
| Store notification DTO | Sendy.Application/DTOs/Responses/Messaging/StoreNewMessageNotification.cs |
| AI job (push after save) | Sendy.Infrastructure/Jobs/AiMessageDispatchJob.cs |
| Store reply (push to widget) | Sendy.Application/Services/Messaging/SocialConversationService.cs |
| Hub mapping | Sendy.Api/Program.cs |
| DI registration | Sendy.Api/Hosting/WebApplicationBuilderExtensions.cs |
For channel creation, AI agent config, and CRM inbox REST endpoints, see website-chat-widget-flow.md.
Source: DOCS/flows/website-chat-websocket-flow.md