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 in website-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 classRouteAuthGroup format
WebsiteChatHub/hubs/widget-chatAnonymouswidget:{sessionId}
StoreConversationHub/hubs/store-conversationsJWT requiredstore:{storeId}

Both hubs live in Sendy.Infrastructure/Hubs/.


Real-Time Push Matrix

TriggerWidget receivesStore 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

LayerFile
Widget hubSendy.Infrastructure/Hubs/WebsiteChatHub.cs
Store CRM hubSendy.Infrastructure/Hubs/StoreConversationHub.cs
Notifier interfaceSendy.Application/Interfaces/Services/Messaging/IWebsiteChatNotifier.cs
Notifier implementationSendy.Infrastructure/Services/Messaging/SignalRWebsiteChatNotifier.cs
Widget notification DTOSendy.Application/DTOs/Responses/Messaging/WidgetMessageResponse.cs
Store notification DTOSendy.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 mappingSendy.Api/Program.cs
DI registrationSendy.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