chat-adapter-chatwoot 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release, run end to end against Chatwoot 4.18 — account webhooks, API inbox webhooks and an agent bot.
6
+
7
+ - Every Chatwoot channel through one webhook: messages, attachments, voice-note transcripts and locations in; markdown, files, quoted replies, private notes and buffered streams out.
8
+ - Only the contact's own messages reach Chat SDK's handlers. Other agents' and bots' replies, and private notes, come in with `receiveAgentMessages` and `receivePrivateNotes`, so a bot never repeats an internal note to a customer and two bots never answer each other.
9
+ - A card's buttons and select options become an `input_select`, and a pick reaches `onAction` on every channel: the web widget's own report, and the message WhatsApp, Telegram, Facebook and LINE send back instead. Only an option the adapter offered on that card, in that conversation, counts, since a contact can patch the widget's answer through Chatwoot's public API.
10
+ - A card's images go out as attachments of the same message, its fields as bold labels and its tables in a code block.
11
+ - Declared `slashCommands` reach `onSlashCommand`, and `event.channel.post()` answers in the conversation they came from.
12
+ - Modals: a website widget shows one as Chatwoot's native form, and anywhere else each field is asked in turn, with numbers, dates, searches, `skip` and `cancel`. Every card pick and command carries a `triggerId`, and `triggerIdOf` builds one.
13
+ - Deletes reach `onMessageDeleted` and edits `onMessageUpdated`, each once, though Chatwoot fires `message_updated` for any change, resends it, and delivers webhooks out of order. An edit back to earlier content counts as an edit too.
14
+ - Conversation control: status and snooze, assignee and team, priority, labels, custom attributes, read state and mute, with `openDM` reusing a contact's conversation or opening one in their own inbox, and `getUser` reading contacts, then agents.
15
+ - Agent and agent bot tokens alike. A bot token is detected at startup, Chat SDK keeps its thread history since it cannot list messages, and what Chatwoot refuses it surfaces as a `PermissionError`.
16
+ - Webhooks must be signed, and are verified against every secret given, since the account, an API inbox and an agent bot each sign with their own. Without a secret they are refused unless `allowUnverifiedWebhooks` is set. A signature older than five minutes is refused as a replay, an unsigned or oversized body is refused without buffering it, and an event from another account is ignored.
17
+ - The same payload from the account webhook and an API inbox is handled once. An event a newer Chatwoot adds is acknowledged, and a value it adds to an enum passes through, rather than failing the webhook, which would hand an agent bot's conversation to a person.
18
+ - Every request times out, and a GET that fails on the network, with a 5xx, or with a short `Retry-After` is retried with backoff. Writes never are.
19
+ - Files sent with a message keep their line breaks, which `FormData` would have turned into CRLF.
20
+ - Attachments download with the access token on Chatwoot's ActiveStorage paths only, and anything off its origin, the storage redirect included, goes through the shared SSRF-guarded downloader, within a size and time limit.
21
+ - `@chat-adapter/shared` is a peer dependency, so its error classes are the ones your app imports.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nivandres
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,358 @@
1
- # Temporary Holding Version
1
+ [![Chatwoot adapter for Chat SDK](https://chat-sdk.dev/en/adapters/community/chatwoot/og)](https://chat-sdk.dev/adapters/community/chatwoot)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # chat-adapter-chatwoot
4
+
5
+ > npm package: [`chat-adapter-chatwoot`](https://www.npmjs.com/package/chat-adapter-chatwoot)
6
+
7
+ [![npm version](https://img.shields.io/npm/v/chat-adapter-chatwoot?style=flat-square&logo=npm&logoColor=white&labelColor=000&color=000)](https://www.npmjs.com/package/chat-adapter-chatwoot)
8
+ [![npm downloads](https://img.shields.io/npm/dm/chat-adapter-chatwoot?style=flat-square&logo=npm&logoColor=white&labelColor=000&color=000)](https://www.npmjs.com/package/chat-adapter-chatwoot)
9
+ [![MIT License](https://img.shields.io/badge/License-MIT-000?style=flat-square&logo=opensourceinitiative&logoColor=white&labelColor=000&color=000)](LICENSE)
10
+
11
+ Chatwoot adapter for [Chat SDK](https://chat-sdk.dev), using the [Chatwoot](https://www.chatwoot.com) Application API. Your bot answers every channel Chatwoot connects, including the website widget, WhatsApp, email, Telegram, Instagram, and SMS, through one webhook.
12
+
13
+ Documentation: [chat-sdk.dev/adapters/community/chatwoot](https://chat-sdk.dev/adapters/community/chatwoot)
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ pnpm add chat-adapter-chatwoot chat @chat-adapter/shared
19
+ ```
20
+
21
+ `chat` and `@chat-adapter/shared` are peer dependencies, so your app and the adapter share one copy of each. The package is ESM only and needs Node.js 20 or later.
22
+
23
+ ## Usage
24
+
25
+ ```typescript
26
+ import { Chat } from "chat";
27
+ import { createMemoryState } from "@chat-adapter/state-memory";
28
+ import { createChatwootAdapter } from "chat-adapter-chatwoot";
29
+
30
+ const bot = new Chat({
31
+ userName: "mybot",
32
+ adapters: {
33
+ chatwoot: createChatwootAdapter(),
34
+ },
35
+ state: createMemoryState(),
36
+ });
37
+
38
+ bot.onDirectMessage(async (thread, message) => {
39
+ await thread.post(`Hello, ${message.author.fullName}!`);
40
+ });
41
+ ```
42
+
43
+ When using `createChatwootAdapter()` without arguments, credentials are auto-detected from environment variables.
44
+
45
+ Every Chatwoot conversation is a one-to-one thread between a contact and your account, so `onDirectMessage` sees all of them. Only the contact's own messages reach your handlers by default. Replies from other agents and bots, and private notes, stay out until you opt in with `receiveAgentMessages` or `receivePrivateNotes`, so a bot never repeats an internal note to the customer and two bots on one account never answer each other.
46
+
47
+ The adapter keeps card options, edit tracking, and webhook deduplication in the Chat SDK state adapter. Use a persistent one, such as [Redis](https://chat-sdk.dev/adapters/official/redis), in production.
48
+
49
+ ## Chatwoot setup
50
+
51
+ ### 1. Get an access token
52
+
53
+ 1. In Chatwoot, open **Settings > Agents** and add the agent the bot posts as.
54
+ 2. Sign in as that agent, open **Profile Settings**, and copy its **Access Token** into `CHATWOOT_ACCESS_TOKEN`.
55
+ 3. Copy your account ID from the dashboard URL, `/app/accounts/<id>/...`, into `CHATWOOT_ACCOUNT_ID`.
56
+
57
+ An agent bot's token works too, within the limits in [Agent bot tokens](#agent-bot-tokens).
58
+
59
+ ### 2. Choose how webhooks reach the bot
60
+
61
+ Point one or more of these at your webhook route:
62
+
63
+ | Source | Where to set it up | Delivers |
64
+ | --------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | Account webhook | **Settings > Integrations > Webhooks** | The events you subscribe to, across the account |
66
+ | API inbox | The API inbox's **Webhook URL** setting | Events in that inbox |
67
+ | Agent bot | **Settings > Bots**, then assign the bot to an inbox | `message_created`, `message_updated`, `conversation_opened`, `conversation_resolved`, `conversation_status_changed`, `conversation_updated`, and `webwidget_triggered` for its inboxes |
68
+
69
+ Subscribe an account webhook to at least `message_created`. Add `message_updated` for edits, deletes, and card picks from the web widget, plus any `conversation_*`, `contact_*`, or typing event you want in `onEvent`. When the account webhook and an API inbox both deliver the same event, the adapter handles it once.
70
+
71
+ ### 3. Copy the webhook secret
72
+
73
+ Each source signs with its own secret. Copy every one that reaches your route into `CHATWOOT_WEBHOOK_SECRET`, separated by commas, or pass `webhookSecret: [accountSecret, inboxSecret]`. Without a secret, the adapter refuses every webhook with `401`.
74
+
75
+ ### Agent bot tokens
76
+
77
+ Chatwoot's `BOT_ACCESSIBLE_ENDPOINTS` let an agent bot token post messages and private notes, stream, read the conversation, toggle typing, and change status, priority, assignment, labels, and custom attributes. Chatwoot refuses the rest with `401 Access to this endpoint is not authorized for bots`, which the adapter raises as a `PermissionError`: `fetchMessage`, `deleteMessage`, `markAsRead`, `markAsUnread`, `mute`, `unmute`, `listThreads`, `fetchChannelInfo`, `getUser`, `fetchContact`, `listAgents`, and `openDM`. Since a bot token cannot list messages, `fetchMessages` answers empty and Chat SDK serves the thread history it keeps (`persistThreadHistory`). The adapter detects an agent bot token at startup and logs a warning rather than failing.
78
+
79
+ A conversation in an agent bot's inbox starts `pending`, and `toggleStatus({ threadId, status: "open" })` hands it to people. Chatwoot does the same on its own when a `message_created` or `message_updated` delivery to the bot fails.
80
+
81
+ ## Configuration
82
+
83
+ All options are auto-detected from environment variables when not provided. You can call `createChatwootAdapter()` with no arguments if the env vars are set.
84
+
85
+ | Option | Required | Description |
86
+ | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
87
+ | `accessToken` | No\* | Agent or agent bot access token. Auto-detected from `CHATWOOT_ACCESS_TOKEN` |
88
+ | `accountId` | No\* | Chatwoot account ID. Auto-detected from `CHATWOOT_ACCOUNT_ID` |
89
+ | `webhookSecret` | No\*\* | Secret, or list of secrets, that verify `X-Chatwoot-Signature`. Auto-detected from `CHATWOOT_WEBHOOK_SECRET` (comma-separated) |
90
+ | `baseUrl` | No | Chatwoot base URL, for a self-hosted install. Auto-detected from `CHATWOOT_BASE_URL` (defaults to `https://app.chatwoot.com`) |
91
+ | `inboxId` | No | Inbox `openDM` searches and opens new conversations in. Auto-detected from `CHATWOOT_INBOX_ID` |
92
+ | `userName` | No | Bot display name. Auto-detected from `CHATWOOT_USER_NAME` (defaults to the authenticated agent's name) |
93
+ | `adapterName` | No | Adapter identity and thread ID prefix, to run several accounts in one bot (defaults to `"chatwoot"`) |
94
+ | `receiveAgentMessages` | No | Also hand other agents' and bots' replies to message handlers (defaults to `false`) |
95
+ | `receivePrivateNotes` | No | Also hand private notes to message handlers (defaults to `false`) |
96
+ | `slashCommands` | No | Commands, such as `"/help"`, whose messages from the contact reach `onSlashCommand` instead of message handlers |
97
+ | `modalTexts` | No | The phrases a conversational modal sends, to translate them: `cancel`, `cancelHint`, `cancelled`, `invalid`, `noResults`, `search`, `skip`, `skipHint` |
98
+ | `allowUnverifiedWebhooks` | No | Accept webhooks while no `webhookSecret` is set. For local development only (defaults to `false`) |
99
+ | `timestampToleranceMs` | No | How old a signed webhook may be before it reads as a replay (defaults to 5 minutes) |
100
+ | `maxBodyBytes` | No | Largest webhook body read (defaults to 4 MiB) |
101
+ | `timeoutMs` | No | Deadline for one API request or attachment download (defaults to 30 seconds) |
102
+ | `onEvent` | No | Called with every verified webhook payload, including events Chat SDK has no handler for |
103
+ | `logger` | No | Logger used until `Chat` initializes the adapter, which then logs through Chat's logger (defaults to `ConsoleLogger("info")`) |
104
+
105
+ \*Required at runtime, either via config or environment variable.
106
+
107
+ \*\*Required to receive webhooks, unless `allowUnverifiedWebhooks` is set.
108
+
109
+ ## Environment variables
110
+
111
+ ```bash
112
+ CHATWOOT_ACCESS_TOKEN=... # Agent or agent bot access token
113
+ CHATWOOT_ACCOUNT_ID=... # Account ID, from /app/accounts/<id>/ in the dashboard
114
+ CHATWOOT_WEBHOOK_SECRET=... # Webhook secret; separate several with commas
115
+ CHATWOOT_BASE_URL=... # Optional, self-hosted Chatwoot URL (defaults to https://app.chatwoot.com)
116
+ CHATWOOT_INBOX_ID=... # Optional, inbox openDM uses for new conversations
117
+ CHATWOOT_USER_NAME=... # Optional, bot display name
118
+ ```
119
+
120
+ ## Webhook setup
121
+
122
+ ```typescript
123
+ // Next.js App Router example: app/api/webhooks/chatwoot/route.ts
124
+ import { after } from "next/server";
125
+ import { bot } from "@/lib/bot";
126
+
127
+ export async function POST(request: Request) {
128
+ return bot.webhooks.chatwoot(request, {
129
+ waitUntil: (task) => after(() => task),
130
+ });
131
+ }
132
+ ```
133
+
134
+ The adapter verifies the request, answers `200`, and runs your handlers in the background through `waitUntil`. Chatwoot counts any other status as a failed delivery, so an event type the adapter doesn't know yet is acknowledged with `200` and ignored.
135
+
136
+ ## Features
137
+
138
+ ### Messaging
139
+
140
+ | Feature | Supported |
141
+ | ------------------ | -------------------------------------------------------------------------------------------------- |
142
+ | Post message | Yes |
143
+ | Message replies | Outbound only (`thread.reply()` quotes with Chatwoot's `in_reply_to`) |
144
+ | Edit message | No (Chatwoot has no message edit API) |
145
+ | Delete message | Yes (Chatwoot leaves a "deleted" placeholder) |
146
+ | File uploads | Yes (up to 15 per message) |
147
+ | Streaming | Buffered (Chatwoot can't edit a sent message, so a stream posts once, behind the typing indicator) |
148
+ | Scheduled messages | No |
149
+
150
+ ### Rich content
151
+
152
+ | Feature | Supported |
153
+ | --------------- | ---------------------------------------------------------------------------------------- |
154
+ | Card format | Partial (markdown text, buttons and selects as an `input_select`, images as attachments) |
155
+ | Buttons | Yes (`input_select` options; a pick reaches `onAction` on every channel) |
156
+ | Link buttons | Partial (markdown links) |
157
+ | Select menus | Partial (options as `input_select` buttons, or a native select in a widget modal) |
158
+ | Tables | Partial (monospaced text in a code block) |
159
+ | Charts | No |
160
+ | Fields | Partial (`**Label:** value` lines) |
161
+ | Images in cards | Yes (attachments of the same message) |
162
+ | Modals | Partial (a native form in the website widget, a conversation elsewhere) |
163
+
164
+ ### Conversations
165
+
166
+ | Feature | Supported |
167
+ | --------------------- | -------------------------------------------------------------------------------------------- |
168
+ | Slash commands | Partial (declared commands, sent by the contact) |
169
+ | Mentions | Partial (Chatwoot notifies mentions in private notes only; build one with `chatwootMention`) |
170
+ | Add reactions | No (Chatwoot has no reaction API) |
171
+ | Remove reactions | No |
172
+ | Typing indicator | Partial (shown to agents and in the web widget) |
173
+ | Mark as read | Partial (per conversation, not per message) |
174
+ | Message edit events | Yes |
175
+ | Message delete events | Yes |
176
+ | DMs | Yes (every conversation; `openDM` reuses or opens one) |
177
+ | Ephemeral messages | No |
178
+ | User lookup | Partial (contacts first, then agents) |
179
+ | Parent subject | Yes (the conversation, its labels, assignee, and dashboard link) |
180
+ | Native client | Partial (`fetch()` reaches any Application API route) |
181
+ | Custom API endpoint | Yes (`baseUrl`, for self-hosted Chatwoot) |
182
+
183
+ ### Message history
184
+
185
+ | Feature | Supported |
186
+ | ---------------------- | ------------------------------------------------------ |
187
+ | Fetch messages | Yes (Chat SDK keeps the history for agent bot tokens) |
188
+ | Fetch single message | Yes |
189
+ | Fetch thread info | Yes |
190
+ | Fetch channel messages | No |
191
+ | List threads | Yes (25 conversations per page, newest activity first) |
192
+ | Fetch channel info | Yes |
193
+ | Post channel message | No |
194
+
195
+ `fetchMessages` pages back 20 messages at a time and forward up to 100. It returns private notes and activity messages too, so filter on `message.raw.private` and `message.author.isSystem`.
196
+
197
+ ### Incoming message types
198
+
199
+ | Type | Supported |
200
+ | ----------------------------------- | ----------------------------------------------------------------------- |
201
+ | Text | Yes (markdown, with links collected) |
202
+ | Images, video, audio, and files | Yes (downloaded on demand through `fetchData()`) |
203
+ | Voice notes | Yes (Chatwoot's transcript becomes the text when the message has none) |
204
+ | Locations | Yes (`Location: <name> (<lat>, <long>)` as the text) |
205
+ | Instagram posts, reels, and stories | Yes (as images and videos) |
206
+ | Card picks | Yes (reach `onAction`, with a `triggerId`, instead of message handlers) |
207
+ | Declared slash commands | Yes (reach `onSlashCommand`) |
208
+ | Modal answers and widget forms | Yes (reach `onModalSubmit`) |
209
+ | Edits and deletes | Yes (`onMessageUpdated` and `onMessageDeleted`) |
210
+ | Other agents' and bots' replies | Opt-in (`receiveAgentMessages`) |
211
+ | Private notes | Opt-in (`receivePrivateNotes`) |
212
+ | Activity messages | No (Chatwoot never sends them as webhooks) |
213
+
214
+ Chatwoot fires `message_updated` for any change, delivery status included, and delivers webhooks in no particular order. An update reaches `onMessageUpdated` only when its content differs from what the adapter last saw for that message, and an update that arrives before its message is no edit. Deletes reach `onMessageDeleted` once.
215
+
216
+ [sample-messages.md](sample-messages.md) shows the payload shapes Chatwoot sends.
217
+
218
+ ### Cards and buttons
219
+
220
+ A card's text renders as markdown, with fields as `**Label:** value` lines and tables in a code block, and its images go out as attachments of the same message, which every inbox delivers. Its buttons and select options become an `input_select`, the only interactive content Chatwoot patches back onto a message. Each channel answers it its own way:
221
+
222
+ - The web widget reports the pick on the card itself.
223
+ - WhatsApp renders native buttons (up to 3, then a list) and sends the tapped button's title back, quoting the card.
224
+ - Telegram and LINE send the button's value, and Facebook its title.
225
+ - Instagram and email show the options as text, and a reply that is exactly an option's title or value counts as the pick.
226
+
227
+ The adapter keeps each card's options in Chat SDK state for 24 hours. A message that matches an option of the card it quotes, or of the conversation's latest card, fires `onAction` and never reaches your message handlers. Keep labels under 20 characters, which WhatsApp enforces and Chatwoot doesn't check, and values under 64 bytes for Telegram. Link buttons render as markdown links, and a card posted as a private note goes without its options. Chatwoot's own `cards` content is not used: its postback buttons only raise an event in the visitor's browser and never reach Chatwoot.
228
+
229
+ A card image keeps the name its URL ends with, and Chatwoot shows it as an image when that name has an image extension, such as `.png` or `.jpg`.
230
+
231
+ ### Slash commands
232
+
233
+ Chatwoot has no slash commands, so list yours in `slashCommands`. A contact's message whose first word matches one, ignoring case, reaches `onSlashCommand` instead of the message handlers: `"/help me"` arrives as command `/help` with text `"me"`, and any other text starting with `/` stays a message. The event's channel is the conversation, so `event.channel.post()` answers there.
234
+
235
+ ```typescript
236
+ createChatwootAdapter({ slashCommands: ["/help"] });
237
+
238
+ bot.onSlashCommand("/help", async (event) => {
239
+ await event.channel.post(`Looking into ${event.text}`);
240
+ });
241
+ ```
242
+
243
+ ### Modals
244
+
245
+ `event.openModal()` works from every slash command and card pick, since each carries a `triggerId`. Build one yourself with `triggerIdOf(threadId, contactId)` to call `adapter.openModal()` directly.
246
+
247
+ - **In a website widget**, a modal whose fields are all required text inputs, text areas, selects, or radio selects shows as Chatwoot's native form, and the values the visitor submits reach `onModalSubmit`. The adapter only accepts the fields it asked, with the options it offered.
248
+ - **Anywhere else**, or with a number, date, external select, optional, or length-limited field, the modal runs as a conversation. Each field is asked in turn, a select as numbered options answered by number or label, and a reply that doesn't fit is asked again. `skip` keeps an optional field empty or its initial value, an external select searches through `onOptionsLoad`, and `cancel`, or the modal's `closeLabel`, stops it, telling `onModalClose` when the modal sets `notifyOnClose`.
249
+
250
+ Either way, an `errors` response asks again for just those fields, and `update` or `push` opens the new modal. An open modal waits a day for its answers, kept in Chat SDK state per account, conversation, and contact. Set `modalTexts` to put the conversation's few phrases in your contacts' language.
251
+
252
+ ## Chatwoot extensions
253
+
254
+ Reach Chatwoot features that have no Chat SDK equivalent through `requireChatwootAdapter(thread)`, or narrow with `isChatwootAdapter(thread.adapter)` when the bot runs other adapters too:
255
+
256
+ ```typescript
257
+ import { chatwootMention, requireChatwootAdapter } from "chat-adapter-chatwoot";
258
+
259
+ bot.onDirectMessage(async (thread, message) => {
260
+ const chatwoot = requireChatwootAdapter(thread);
261
+
262
+ await chatwoot.postPrivateNote(
263
+ thread.id,
264
+ `${chatwootMention(30, "Bruce")} needs a look`,
265
+ );
266
+ await chatwoot.setLabels(thread.id, ["needs-human"]);
267
+ await chatwoot.assign({ threadId: thread.id, assigneeId: 30 });
268
+ await chatwoot.toggleStatus({ threadId: thread.id, status: "open" });
269
+ });
270
+ ```
271
+
272
+ | Method | Description |
273
+ | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
274
+ | `postPrivateNote(threadId, message)` | Post a private note, which agents see and the contact never does. Mentions built with `chatwootMention(id, name)` notify the agent or team |
275
+ | `toggleStatus({ threadId, status, snoozedUntil })` | Open, resolve, snooze, or move a conversation back to pending |
276
+ | `assign({ threadId, assigneeId, teamId })` | Assign an agent, a team, or both. Pass `null` to unassign |
277
+ | `setPriority({ threadId, priority })` | Set the priority, or clear it with `null` |
278
+ | `setLabels(threadId, labels)` | Replace the conversation's labels |
279
+ | `setCustomAttributes(threadId, attributes)` | Replace the conversation's custom attributes; Chatwoot doesn't merge them |
280
+ | `mute(threadId)` / `unmute(threadId)` | Resolve the conversation and **block the contact across the account**, then unblock them. This is Chatwoot's mute, not a notification setting |
281
+ | `markAsUnread(threadId)` | Restore the conversation's unread badge |
282
+ | `openDM(contactId)` | Reuse one of the contact's conversations, preferring an unresolved one, or open one in their own inbox (or `inboxId`) when they have none |
283
+ | `openModal(triggerId, modal)` | Open a modal for the contact a `triggerIdOf(threadId, contactId)` names, as described in [Modals](#modals) |
284
+ | `getUser(userId)` | Look a user up as a contact, then among agents when no contact has that ID. Contacts and agents are numbered apart, so an agent that shares a contact's ID resolves to the contact |
285
+ | `fetchContact(contactId)` | Fetch a contact's full record |
286
+ | `listAgents()` | List the account's agents |
287
+ | `fetch(endpoint, request)` | Call any Application API route under `/api/v1`, with typed errors, the request timeout, and retries |
288
+
289
+ `onEvent` receives every verified payload, so conversation lifecycle, typing, contact, and inbox events are available too:
290
+
291
+ ```typescript
292
+ createChatwootAdapter({
293
+ onEvent: (event) => {
294
+ if (event.event === "conversation_resolved") metrics.increment("resolved");
295
+ },
296
+ });
297
+ ```
298
+
299
+ A GET that fails on the network, times out, or gets a 5xx is retried up to three times with backoff, and so is a 429 whose `Retry-After` is 5 seconds or less. Writes are never retried, since Chatwoot cannot tell a retried write from a new one. Timeouts and 5xx responses raise `NetworkError`, and other failures map onto the matching `@chat-adapter/shared` error.
300
+
301
+ ## Security
302
+
303
+ - **Signatures.** Every webhook must carry an `X-Chatwoot-Signature`, the HMAC-SHA256 of `{timestamp}.{body}`, that matches one of the configured secrets. A signature more than five minutes old is refused as a replay. An unsigned body is refused before it is read, and an oversized one before it is buffered. Without a secret every webhook is refused. `allowUnverifiedWebhooks` lifts that for local development, and anyone who can reach the route can then post as any contact.
304
+ - **Account check.** Thread IDs come from the payload, while replies go to the configured account. Every webhook names its account, a typing event through its conversation, and one that names another account is acknowledged and ignored, `onEvent` included. A secret shared across accounts, such as a global agent bot's, therefore cannot make the bot answer in the wrong one. A message that names no account is ignored as well.
305
+ - **Private notes and agents.** Only the contact's messages reach your handlers unless you opt in, as described in [Usage](#usage).
306
+ - **Card picks and forms.** A contact can rewrite any message's `submitted_values` through Chatwoot's public API. The adapter only accepts an option it offered on that card, in that conversation, within 24 hours, and a private note's card offers none. A form counts only when it is the one the adapter sent that contact, answered with exactly its fields and options.
307
+ - **Access token.** The token goes only to Chatwoot's API and its ActiveStorage file paths on the configured origin. Attachment URLs off that origin, including the storage service ActiveStorage redirects to, go through the shared SSRF-guarded downloader: HTTPS only, private addresses refused, and every redirect checked again. Downloads stop at 40 MB and at `timeoutMs`.
308
+
309
+ ## Thread ID format
310
+
311
+ ```
312
+ chatwoot:{inboxId}:{contactId}:{conversationId}
313
+ ```
314
+
315
+ Example: `chatwoot:10:20:300`
316
+
317
+ `conversationId` is Chatwoot's account-scoped `display_id`, the ID every conversation route takes. `contactId` is `0` when a payload names no contact. Channel IDs are `chatwoot:{inboxId}`.
318
+
319
+ To run two Chatwoot accounts in one bot, give each adapter an `adapterName` and register it under that same key, so `chat.thread(threadId)` resolves the right one:
320
+
321
+ ```typescript
322
+ adapters: {
323
+ "chatwoot-sales": createChatwootAdapter({ adapterName: "chatwoot-sales", accountId: 2 }),
324
+ "chatwoot-support": createChatwootAdapter({ adapterName: "chatwoot-support", accountId: 3 }),
325
+ }
326
+ ```
327
+
328
+ ## Troubleshooting
329
+
330
+ ### Every webhook is refused with 401
331
+
332
+ - `No webhook secret configured`: set `CHATWOOT_WEBHOOK_SECRET` or `webhookSecret`.
333
+ - `Invalid signature`: the secret doesn't match the source. The account webhook, each API inbox, and each agent bot sign with their own, so pass every one that reaches the route.
334
+ - `Stale signature`: the request's timestamp is more than five minutes from the server clock. Check the clock, or raise `timestampToleranceMs`.
335
+
336
+ ### Every agent bot conversation opens at once
337
+
338
+ Chatwoot hands a conversation in an agent bot's inbox to people whenever a `message_created` or `message_updated` delivery fails: any status but `200`, a timeout, or the route being down. A missing or wrong secret shows up exactly like this.
339
+
340
+ ### Agents' replies or private notes never reach the handlers
341
+
342
+ That is the default. Set `receiveAgentMessages` or `receivePrivateNotes` to receive them.
343
+
344
+ ### Events are ignored as "from another Chatwoot account"
345
+
346
+ The payload's account differs from `accountId`. Check `CHATWOOT_ACCOUNT_ID`, or run one adapter per account with its own `adapterName`.
347
+
348
+ ### `PermissionError` mentioning "agent access token"
349
+
350
+ The token belongs to an agent bot, and Chatwoot refuses that method for bots. Use an agent's access token for the full adapter, as described in [Agent bot tokens](#agent-bot-tokens).
351
+
352
+ ### Attachment downloads fail with `NetworkError`
353
+
354
+ A file outside Chatwoot's origin must be on a public HTTPS host. Storage on a private network or over plain HTTP is refused by the SSRF guard, as are files over 40 MB.
355
+
356
+ ## License
357
+
358
+ MIT