Flows
Website Chat Widget Flow
Embedding and using the website chat widget.
Website Chat Widget — Full Feature Flow
Feature area: Social Messaging / Website Chat
Branch:phase-2
Last updated: 2026-06-09
Superseded — real-time delivery only. As of 2026-06-12 the website chat widget uses SignalR WebSockets for real-time push in both directions. The poll endpoint (
GET /widget/chat/{token}/messages) has been removed. Seewebsite-chat-websocket-flow.mdfor the current implementation. This document remains useful for: channel creation, embed snippet, store-side CRM endpoints, and the REST fallback (POST /widget/chat/{token}/messages) which is still supported.
Overview
The Website Chat Widget lets a store embed a live-chat widget on any website. Unlike WhatsApp and Instagram, there is no OAuth handshake — the store creates a channel via a single API call and receives an embed snippet. Customers interact through the widget; the store replies either manually from the CRM inbox or automatically via the AI agent.
Delivery for this channel is poll-based: the widget periodically asks the server for new messages. Delivery is now WebSocket-based (SignalR). The polling flow described below is superseded. No external API, no webhooks, no Meta credentials required.
How It Differs from WhatsApp / Instagram
| WhatsApp / Instagram | Website Chat | |
|---|---|---|
| Connection | OAuth → Meta | Single API call |
| Credential storage | Encrypted token + app secret | None (token is the channel identity) |
| Inbound delivery | Meta sends POST to webhook | Widget POSTs directly to Sendy |
| Outbound delivery | Graph API call | DB write → widget polls |
| AI agent | Yes | Yes |
| Manual store reply | Yes | Yes |
Entities Used
The same SocialChannel, SocialConversation, SocialMessage, and AiMessageJob entities from the social messaging feature are reused without modification. Website channels are identified by ChannelType = Website and use ExternalAccountId as the channel token (a random Guid.NewGuid().ToString("N")).
SocialChannel.ChannelToken is exposed in API responses only when ChannelType == Website — it is the same value as ExternalAccountId, surfaced separately for clarity.
Flow 1 — Store Creates a Website Channel
POST /api/v1/store/social/channels/website
Authorization: Bearer <store JWT>
Permission: store.social_channels.manage
Body:
{
"displayName": "Support Chat",
"aiProviderCode": "anthropic", // optional, default: anthropic
"aiModelCode": "claude-sonnet-4-6", // optional, default: claude-sonnet-4-6
"systemPromptOverride": null // optional
}
Server (WebsiteChatService.CreateChannelAsync):
1. Verify store context (JWT → storeId)
2. EnsureCanConnectChannelAsync → check AllowsSocialMessaging + MaxSocialChannels
3. Check no existing Website channel for this store (409 if one already exists)
4. channelToken = Guid.NewGuid().ToString("N")
5. Create SocialChannel {
ChannelType = Website,
ExternalAccountId = channelToken, ← this IS the token
AccessTokenEncrypted = "" ← no Meta token needed
}
6. Return CreateWebsiteChannelResponse
Response:
{
"channelId": "3fa85f64-...",
"channelToken": "a1b2c3d4e5f6...",
"embedSnippet": "<script src=\"https://api.example.com/widget.js\" data-channel=\"a1b2c3...\" async></script>"
}
The store pastes embedSnippet into their website's HTML. One channel per store — attempting to create a second returns 409.
Note:
GET /api/v1/store/social/channels/auth/websitereturns400— Website channels do not use OAuth. The guard lives inMetaOAuthService.BuildAuthorizationUrlAsync.
Flow 2 — Customer Sends a Message
The widget calls this endpoint directly (no authentication required):
POST /api/v1/widget/chat/{channelToken}/messages
Body:
{
"sessionId": "visitor-uuid-or-fingerprint",
"text": "Hello, I have a question about my order",
"contactDisplayName": "Ahmed" // optional, set once
}
Server (WebsiteChatService.SendMessageAsync):
1. Validate channelToken → load SocialChannel (ChannelType=Website, not deleted)
2. Check channel.IsEnabled (422 if disabled)
3. Upsert SocialConversation keyed on (SocialChannelId, sessionId):
- First message: create conversation with Status=Open
- Subsequent messages: update ContactDisplayName if not yet set
4. Create SocialMessage { Direction=Inbound, Status=Delivered }
5. If channel.IsEnabled && channel.IsAgentEnabled:
Create AiMessageJob { Status=Pending } → AI will reply automatically
6. Return full conversation history (all messages for this sessionId)
Response:
{
"conversationId": "guid...",
"messages": [
{ "id": "...", "direction": "Inbound", "contentText": "Hello...", "isAiGenerated": false, "createdAt": "..." },
{ "id": "...", "direction": "Outbound", "contentText": "Hi! How can I help?", "isAiGenerated": true, "createdAt": "..." }
]
}
The widget uses sessionId to tie all messages to the same visitor. The choice of sessionId is left to the frontend — a localStorage UUID works well.
Flow 3 — Widget Polls for New Messages
The widget calls this endpoint on an interval to pick up replies:
GET /api/v1/widget/chat/{channelToken}/messages
?sessionId=visitor-uuid
&after=2026-06-09T10:05:00Z // optional — only return messages after this timestamp
Server (WebsiteChatService.PollMessagesAsync):
1. Load channel by token
2. Load conversation by (channelId, sessionId)
3. If no conversation yet → return empty message list (not an error)
4. Query SocialMessages where ConversationId = conv.Id AND CreatedAt > after
5. Return messages ordered by CreatedAt ASC
The after parameter lets the widget do incremental polling — only fetching messages it hasn't seen yet. On first load, omit after to get the full history.
Flow 4 — AI Agent Replies Automatically
When IsAgentEnabled = true, the Hangfire background job picks up the AiMessageJob created in Flow 2:
AiMessageDispatchJob (runs every minute):
1. Load pending jobs for Website channels
2. Fetch last 20 messages as conversation history
3. Build system prompt (AiSystemPromptBuilder)
4. Call configured AI provider (Anthropic / OpenAI)
5. On success:
- Create SocialMessage { Direction=Outbound, IsAiGenerated=true }
- NO external API call (Website channel — DB write is the delivery)
- Update AiMessageJob { Status=Sent }
- SocialTokenTracker.RecordUsageAsync(inputTokens, outputTokens)
6. On failure:
- Exponential backoff; max 3 attempts before Status=Dead
The reply appears in the widget on the next poll — typically within seconds.
Flow 5 — Store Staff Replies Manually
Store staff open the CRM inbox and send a message from the dashboard:
POST /api/v1/store/social/conversations/{conversationId}/messages
Authorization: Bearer <store JWT>
Permission: store.conversations.manage
Body: { "text": "Your order ships tomorrow!" }
Server (SocialConversationService.SendMessageAsync):
1. Load conversation → verify it belongs to this store
2. Check channel.IsEnabled
3. ChannelType == Website → skip external API call
4. Create SocialMessage {
Direction = Outbound,
IsAiGenerated = false,
SentByUserId = current.Id,
Status = Sent
}
5. Update conversation.LastMessageAt
6. Return SocialMessageResponse
The widget picks up the reply on its next poll — same delivery path as the AI agent.
Complete Flow Diagram
Store admin Sendy API Customer widget
│ │ │
├─ POST /channels/website ────────►│ │
│◄──── { channelToken, snippet } ─┤ │
│ │ │
│ (pastes snippet into website) │ │
│ │ │
│ │◄─── POST /widget/chat/{token}/messages ──┤
│ │ { sessionId, text } │
│ │ │
│ ├── upsert Conversation │
│ ├── save SocialMessage (Inbound) │
│ ├── queue AiMessageJob │
│ │──── return messages ──────────────►│
│ │ │
│ [Hangfire - every minute] │
│ │ │
│ ├── call AI provider │
│ ├── save SocialMessage (Outbound) │
│ │ │
│ │◄── GET /widget/chat/{token}/messages?after=... ┤
│ │──── return new outbound message ──►│
│ │ │
├─ POST /conversations/{id}/messages ►│ │
│ { text: "Manual reply" } │ │
│ ├── save SocialMessage (Outbound) │
│◄──── SocialMessageResponse ─────┤ │
│ │◄── GET /widget/chat/{token}/messages?after=... ┤
│ │──── return manual reply ──────────►│
Endpoints Reference
Store-side (requires JWT + permission)
| Method | Path | Permission | Description |
|---|---|---|---|
POST | /api/v1/store/social/channels/website | store.social_channels.manage | Create the website channel, get embed snippet |
GET | /api/v1/store/social/channels | store.social_channels.view | Lists all channels including website; channelToken is populated for Website type |
PATCH | /api/v1/store/social/channels/{channelId} | store.social_channels.manage | Update display name, AI model, system prompt |
POST | /api/v1/store/social/channels/{channelId}/toggle-agent | store.social_channels.manage | Enable / disable AI auto-reply |
DELETE | /api/v1/store/social/channels/{channelId} | store.social_channels.manage | Disconnect / soft-delete the channel |
GET | /api/v1/store/social/conversations | store.conversations.view | CRM inbox — lists all conversations across all channels |
GET | /api/v1/store/social/conversations/{conversationId} | store.conversations.view | Full message history for one conversation |
POST | /api/v1/store/social/conversations/{conversationId}/messages | store.conversations.manage | Send a manual reply |
POST | /api/v1/store/social/conversations/{conversationId}/resolve | store.conversations.manage | Mark conversation resolved |
POST | /api/v1/store/social/conversations/{conversationId}/reopen | store.conversations.manage | Reopen a resolved conversation |
Widget-side (AllowAnonymous — public)
| Method | Path | Description |
|---|---|---|
POST | /api/v1/widget/chat/{channelToken}/messages | Customer sends a message; returns full conversation history |
GET | /api/v1/widget/chat/{channelToken}/messages?sessionId=&after= | Widget polls for new messages |
Database Migration
dotnet ef database update
Migration: AddWebsiteChannelType
Adds Website = 2 to the SocialChannelType enum at the database level. No new tables — the existing SocialChannels, SocialConversations, and SocialMessages tables handle this channel type transparently.
Source References
| Layer | File |
|---|---|
| Widget controller | Sendy.Api/Controllers/V1/Public/WebsiteChatWidgetController.cs |
| Channel creation endpoint | Sendy.Api/Controllers/V1/Store/StoreSocialChannelsController.cs |
| Manual reply endpoint | Sendy.Api/Controllers/V1/Store/StoreConversationsController.cs |
| Website chat service | Sendy.Application/Services/Messaging/WebsiteChatService.cs |
| Website chat interface | Sendy.Application/Interfaces/Services/Messaging/IWebsiteChatService.cs |
| Conversation service (manual reply) | Sendy.Application/Services/Messaging/SocialConversationService.cs |
| AI dispatcher (Website branch) | Sendy.Infrastructure/Jobs/AiMessageDispatchJob.cs |
| Channel type enum | Sendy.Domain/Enums/Messaging/SocialChannelType.cs |
| Request DTOs | Sendy.Application/DTOs/Requests/Messaging/WebsiteChatRequests.cs |
| Response DTOs | Sendy.Application/DTOs/Responses/Messaging/WebsiteChatResponses.cs |
For the AI agent internals, token billing, and CRM inbox details, see social-messaging-flow.md.
Source: DOCS/flows/website-chat-widget-flow.md