@funnelchat-hq/sdk 0.2.1 → 0.3.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/README.md CHANGED
@@ -1,36 +1,131 @@
1
1
  # funnelchat-node
2
2
 
3
- Official Node.js and TypeScript SDK for the [Funnelchat public API](https://developers-portal.funnelchat.app/docs/): contacts, conversations and messages, WhatsApp forms, templates, tags, custom fields, groups, numbers and webhooks, with types generated from the OpenAPI spec.
3
+ Official Node.js and TypeScript SDK for the [Funnelchat API](https://developers-portal.funnelchat.app/docs/): connect your WhatsApp number, send messages, know when they are read, receive them and handle your groups from your code.
4
4
 
5
- - TypeScript, ESM and CommonJS. Node 18 or later, with Node's own `fetch`.
6
- - No runtime dependencies.
7
- - Safe retries, pagination with `for await`, typed errors carrying the `X-Request-Id` of each response, and webhook signature verification.
8
-
9
- > **v0.** The public API is in v0 and may change. What the API already has but its spec does not describe yet is listed in [Pending of the spec](#pending-of-the-spec).
10
-
11
- ## Install
5
+ - TypeScript, ESM and CommonJS. Node 18 or later, with Node's own `fetch`. No runtime dependencies.
6
+ - Types generated from the OpenAPI spec, safe retries, pagination with `for await`, typed errors with the `X-Request-Id` of each response, and webhook signature verification.
12
7
 
13
8
  ```bash
14
9
  npm install @funnelchat-hq/sdk
15
10
  ```
16
11
 
17
- ## First call
12
+ ## First use
13
+
14
+ ### 1. Create your key
18
15
 
19
- You need an API key (`<id>|fc_live_...`) created in the "Developers" section of the panel, with the permissions of what you are going to use. The panel groups them by area, with one checkbox per level: this README names each one as the panel shows it, with its literal in parentheses, such as **WhatsApp numbers → View** (`accounts:read`). The literal is what the panel shows as a tooltip and what the API names in its errors. In the panel, **Send** includes **View** and **Edit**, and **Edit** includes **View**. Keep it in an environment variable: the client reads `FUNNELCHAT_API_KEY` when you do not pass it.
16
+ In the Funnelchat panel, go to **Developers** and tap **Create key**, with the permissions of what you will use. Copy it: it is shown only once. Keep it in the `FUNNELCHAT_API_KEY` environment variable, where the client reads it.
20
17
 
21
18
  ```ts
22
19
  import { Funnelchat } from '@funnelchat-hq/sdk';
23
- // CommonJS: const { Funnelchat } = require('@funnelchat-hq/sdk');
24
20
 
25
- const funnelchat = new Funnelchat({ apiKey: process.env.FUNNELCHAT_API_KEY });
21
+ const funnelchat = new Funnelchat(); // reads FUNNELCHAT_API_KEY
22
+ ```
23
+
24
+ ### 2. Connect a number
26
25
 
27
- // Your WhatsApp numbers. Permission: WhatsApp numbers → View (accounts:read). The `id` of each one is the `device_id` you send with.
28
- for await (const device of funnelchat.devices.list()) {
29
- console.log(device.id, device.number, device.capabilities.messages.templates);
26
+ Create a number and show its QR code until it is scanned from WhatsApp → **Linked devices**:
27
+
28
+ ```ts
29
+ const { data: device } = await funnelchat.devices.create({ connection: 'qr' });
30
+
31
+ while (true) {
32
+ const { data: qr } = await funnelchat.devices.qr(device.id);
33
+ if (qr.connected) break;
34
+ show(qr.qr_code); // your code: a PNG data URI, replaced on every turn
35
+ await new Promise((resolve) => setTimeout(resolve, qr.expires_in * 1000)); // 5 seconds
30
36
  }
31
37
  ```
32
38
 
33
- Client options:
39
+ Is the phone someone else's? Send them a page that shows the QR instead: `const { data: link } = await funnelchat.connectLinks.create({ type: 'qr' })` and share `link.url`. Either way, the `device.connected` webhook says when it is connected. More in [Connect a number](#connect-a-number).
40
+
41
+ ### 3. Send a message
42
+
43
+ The contact goes first; then the text, from your number:
44
+
45
+ ```ts
46
+ const contact = await funnelchat.contacts.create({ phone: '5491100000000', name: 'Ana' }); // with country code, digits only
47
+
48
+ const { message } = await funnelchat.messages.sendDirect({ device_id: device.id, contact_id: contact.id, text: 'Hi Ana!' });
49
+ console.log(message.id, message.status); // 1002 'queued'
50
+ ```
51
+
52
+ The answer is the queued message: accepted is not delivered yet. With an official number, free text only goes within 24 hours of the contact's last message; after that, an [approved template](#templates).
53
+
54
+ ### 4. Know when it was read
55
+
56
+ Subscribe once to `message.status_changed` and verify each delivery with your secret:
57
+
58
+ ```ts
59
+ const subscription = await funnelchat.webhooks.create({
60
+ url: 'https://your-app.example/hooks/funnelchat',
61
+ events: ['message.status_changed'],
62
+ });
63
+ // Keep subscription.secret (FUNNELCHAT_WEBHOOK_SECRET below): it is shown only once.
64
+
65
+ app.post('/hooks/funnelchat', express.raw({ type: 'application/json' }), (req, res) => {
66
+ const event = Funnelchat.webhooks.constructEvent(req.body, req.get('X-Funnelchat-Signature'), process.env.FUNNELCHAT_WEBHOOK_SECRET!);
67
+ if (event.type === 'message.status_changed' && event.data.status === 'read') {
68
+ console.log(`Message ${event.data.message_id} was read`);
69
+ }
70
+ res.sendStatus(200);
71
+ });
72
+ ```
73
+
74
+ The status goes `sent`, `delivered`, `read` (or `failed`, with `error`). You can also ask for it: `(await funnelchat.messages.get(contact.id, message.id)).status`. More in [Webhooks](#webhooks).
75
+
76
+ ## Receive messages
77
+
78
+ Add `message.received` to the events of your subscription. Each one brings the message, the contact and the number that received it, the one to answer from:
79
+
80
+ ```ts
81
+ if (event.type === 'message.received') {
82
+ const { message, contact, device_id } = event.data;
83
+ console.log(contact.phone, message.text ?? message.type); // text is null for a file without caption
84
+
85
+ if (device_id && contact.id) {
86
+ await funnelchat.messages.sendDirect({ device_id, contact_id: contact.id, text: 'Got it, thanks!' });
87
+ }
88
+ }
89
+ ```
90
+
91
+ ## Groups
92
+
93
+ List the groups of your numbers and send a message to one by its `uid`:
94
+
95
+ ```ts
96
+ for await (const group of funnelchat.groups.list()) {
97
+ console.log(group.uid, group.name); // '120363000000000001@g.us', 'Customers'
98
+ }
99
+
100
+ const { action_id } = await funnelchat.groups.sendMessage('120363000000000001@g.us', {
101
+ device_id: device.id, // a QR number must be admin of the group
102
+ type: 'text',
103
+ text: 'Hi everyone!',
104
+ });
105
+ ```
106
+
107
+ - The message goes out in the background: `group_action.completed` (with `action.uuid` equal to `action_id`) or `funnelchat.groups.retrieveAction(action_id)` tell you how it went. A file goes as `type: 'image'` (or `video`, `audio`, `document`) with its `https` `media_url`.
108
+ - Who joins or leaves arrives by `group_participant.joined` and `group_participant.left`: `group`, `participant` (`identity`, `identity_type`, `name`) and `campaign`, `null` for a group without a campaign.
109
+ - Permissions: **Groups and campaigns → View** to read and **Groups and campaigns → Send** to send.
110
+
111
+ ## Workspaces
112
+
113
+ Each key belongs to the workspace where it was created (the one selected at the top of the panel) and only sees and changes what is in it: numbers, contacts, messages, groups, connect links and webhook subscriptions. Anything of another workspace answers `404`, as if it did not exist. Working for several clients? Create a key in each client's workspace.
114
+
115
+ Every webhook event says which workspace it belongs to, and a subscription only receives the events of its own workspace:
116
+
117
+ ```ts
118
+ const event = Funnelchat.webhooks.constructEvent(rawBody, signature, secret);
119
+ console.log(event.type, event.data.workspace_id); // 'message.received', 37
120
+ ```
121
+
122
+ An account with a single workspace, or without workspaces, works as always: its events carry the id of its default workspace. The caps of 20 keys and 50 subscriptions count per workspace.
123
+
124
+ ---
125
+
126
+ ## Reference
127
+
128
+ ### Client options
34
129
 
35
130
  | Option | What it is | Default |
36
131
  |---|---|---|
@@ -42,29 +137,43 @@ Client options:
42
137
  | `defaultHeaders` | Headers sent with every request. | — |
43
138
  | `fetch` | Another `fetch` implementation. | Node's |
44
139
 
45
- Every request sends `Authorization: Bearer <key>`, `Accept: application/json` and `User-Agent: funnelchat-node/<version> node/<version>`. The key never shows up in a `console.log` of the client or in an error.
140
+ CommonJS: `const { Funnelchat } = require('@funnelchat-hq/sdk');`. Every request sends `Authorization: Bearer <key>`, `Accept: application/json` and `User-Agent: funnelchat-node/<version> node/<version>`. The key never shows up in a `console.log` of the client or in an error.
46
141
 
47
- ## The pieces
142
+ ### The pieces
48
143
 
49
144
  | Resource | What it has |
50
145
  |---|---|
146
+ | `funnelchat.devices` | Your WhatsApp numbers: `list` (filter by `external_reference`), `create` (a QR number), `qr`, `retrieve` (with `capabilities`), `update`, `delete`, `status`, `health`, `me`, `checkPhone`, connection history. The `id` of a number is the `device_id` you send with. |
147
+ | `funnelchat.connectLinks` | `create`, `get`, `list`, `revoke`: a page where your customer connects a number. |
51
148
  | `funnelchat.contacts` | `list`, `create`, `upsert`, `retrieve`, `update`, `delete`, the tags and fields of a contact, `metaWindow` (the 24-hour window), notes. |
52
- | `funnelchat.conversations` | `list`, `update`, counters, mark everything as read or unread. |
53
149
  | `funnelchat.messages` | `send`, `sendDirect`, `sendForm`, `sendContactCard`, `forward`, `get` (one message by id), `list` (the history of a contact), `react`, `removeReaction`. |
54
- | `funnelchat.forms` | WhatsApp forms: `list`, `templates`, `create`, `retrieve`, `update`, `delete`, `createVersion`, `sync`, `publish`, numbers, `versions`, answers and `metrics`. |
55
150
  | `funnelchat.templates` | `list`, `create`, `retrieve`, `update`, `quickReplyButtons`, `send`, `delete`. |
151
+ | `funnelchat.groups` | `list`, `retrieve`, `listByDevice`, participants and their joins and leaves, messages, `sendMessage` and `retrieveAction`. |
152
+ | `funnelchat.webhooks` | Subscriptions (`list`, `create`, `retrieve`, `update`, `delete`, `test`, `listDeliveries`), the catalog (`events`), `verify` and `constructEvent`. |
153
+ | `funnelchat.conversations` | `list`, `update`, counters, mark everything as read or unread. |
154
+ | `funnelchat.forms` | WhatsApp forms: `list`, `templates`, `create`, `retrieve`, `update`, `delete`, `createVersion`, `sync`, `publish`, numbers, `versions`, answers and `metrics`. |
56
155
  | `funnelchat.tags` | `list`, `create`, `retrieve`, `update`, `delete`, `listContacts`. |
57
156
  | `funnelchat.customFields` | `list`, `create`, `retrieve`, `update`, `delete`. |
58
- | `funnelchat.groups` | Read groups, participants, events and messages. |
59
- | `funnelchat.devices` | `list` (filter by `external_reference`), `create` (a QR number), `qr`, `retrieve` (with `capabilities`), `update`, `delete`, `status`, `health`, `me`, `checkPhone`, connection history. |
60
- | `funnelchat.connectLinks` | `create`, `get`, `list`, `revoke`: a page where your customer connects a number. See [Connect a number](#connect-a-number). |
61
- | `funnelchat.webhooks` | Subscriptions (`list`, `create`, `retrieve`, `update`, `delete`, `test`, `listDeliveries`), the catalog (`events`), `verify` and `constructEvent`. |
62
157
 
63
158
  Parameters, bodies and responses are typed from the spec. For any other route, see [Any route](#any-route).
64
159
 
65
- ## Send a message
160
+ ### Permissions
161
+
162
+ A key has the permissions ticked when it was created. The panel groups them by area, with one checkbox per level, and this README names each one as the panel shows it, such as **WhatsApp numbers → View**. In the panel, **Send** includes **View** and **Edit**, and **Edit** includes **View**.
163
+
164
+ | To | You need |
165
+ |---|---|
166
+ | List numbers, read their status and connect links | **WhatsApp numbers → View** |
167
+ | Create numbers, show their QR, create and revoke connect links, delete numbers | **WhatsApp numbers → Create, connect and delete** |
168
+ | Create contacts | **Contacts and conversations → Edit** |
169
+ | Send messages and templates | **Contacts and conversations → Send** |
170
+ | Read groups | **Groups and campaigns → View** |
171
+ | Send to a group | **Groups and campaigns → Send** |
172
+ | Webhook subscriptions | **Notifications (webhooks) → View** to read, **Notifications (webhooks) → Manage** for the rest, plus **View** of the area of each event |
173
+
174
+ ### Send a message
66
175
 
67
- Sending with a key needs **Contacts and conversations → Send** (`conversations:send`) and follows the WhatsApp rules. **Every send answers right away with the registered message, `status: 'queued'`:** accepted is not delivered. What happens next arrives by the `message.status_changed` webhook, or is read with `messages.get`.
176
+ Sending with a key follows the WhatsApp rules. **Every send answers right away with the registered message, `status: 'queued'`:** what happens next arrives by the `message.status_changed` webhook, or is read with `messages.get`.
68
177
 
69
178
  **Open window: free text.** With an official Meta number you can only send free text if the contact wrote to you in the last 24 hours. Check the window first:
70
179
 
@@ -82,10 +191,10 @@ if (window.is_open) {
82
191
  }
83
192
  ```
84
193
 
194
+ - `contacts.create({ phone })` (7 to 15 digits) answers the existing contact when that phone is already there. A contact has to exist before you send it anything.
85
195
  - `message.id` is the same `message_id` of `message.status_changed`. With it, `funnelchat.messages.get(2001, message.id)` tells you how it is going: `status` moves from `queued` to `sent`, `delivered`, `read` or `failed`, and a `failed` one carries `error` (`code`, `title` and `reason`).
86
196
  - `client_reference` (1 to 64 visible ASCII characters, no spaces) comes back in the history of the contact and in `message.status_changed`.
87
- - `contact_id` goes in the body, but `send` fills it with the contact of the route when you leave it out. Send the `phone` (or `whatsapp_id`) of the same contact: when it does not match, the API answers `422 recipient_mismatch`.
88
- - `sendDirect` is the same operation through `POST /api/v1/message/send-message`, with `device_id` and `contact_id` in the body.
197
+ - `send` is `POST /api/v1/contacts/{contact}/message` and checks the `phone` (or `whatsapp_id`) of the body against the contact of the route: when it does not match, `422 recipient_mismatch`. `sendDirect` is `POST /api/v1/message/send-message`, with `device_id` and `contact_id` in the body.
89
198
  - Contact cards (`sendContactCard`) return `{ messages }`, one per card; a forward (`forward`) one item per destination with its `message` (or `null` when that destination was blocked); a template (`templates.send`) and a form (`sendForm`) `{ message }`.
90
199
 
91
200
  **A file by URL.** `file` is the URL of the file. When it does not end in a known extension (a signed URL, or one without extension), say what it is with `file_type` and, for a document, the name it arrives with:
@@ -102,7 +211,9 @@ await funnelchat.messages.send(2001, {
102
211
 
103
212
  Without `file_type`, a URL without a known extension answers `422` with `errors.file_type`, and an unsupported extension `415` (`UnsupportedMediaTypeError`, with `supported_file_types` in `error.body`).
104
213
 
105
- **Closed window: an approved template.** The route takes the number (`3`) and the id of the template (`45`), which comes from `funnelchat.templates.list()`:
214
+ #### Templates
215
+
216
+ **Closed window: an approved template.** It is the only way to write to a contact after 24 hours with an official number. The route takes the number (`3`) and the id of the template (`45`), from `funnelchat.templates.list()`; send only the ones with `available: true` (Meta pauses or disables the others):
106
217
 
107
218
  ```ts
108
219
  const { message } = await funnelchat.templates.send(3, '45', {
@@ -112,7 +223,9 @@ const { message } = await funnelchat.templates.send(3, '45', {
112
223
  });
113
224
  ```
114
225
 
115
- **When a send is stopped.** A send that breaks a rule is neither sent nor stored: you get an error with its `code`.
226
+ #### When a send is stopped
227
+
228
+ A send that breaks a rule is neither sent nor stored: you get an error with its `code`.
116
229
 
117
230
  | Error | What happened | What to do |
118
231
  |---|---|---|
@@ -120,7 +233,7 @@ const { message } = await funnelchat.templates.send(3, '45', {
120
233
  | `422 antiblock_duplicate_content` | The same content repeated to the same contact. | Change the text or wait. |
121
234
  | `422 antiblock_unanswered_limit` | Too many messages in a row to someone who does not answer. | Wait until they answer. |
122
235
  | `422 recipient_mismatch` | The `phone` or `whatsapp_id` is not the contact of the route. | Use that contact's data. |
123
- | `422 recipient_unsupported` | The contact is a group, a broadcast list or a channel. | This route only writes to people. |
236
+ | `422 recipient_unsupported` | The contact is a group, a broadcast list or a channel. | Write to a group with `groups.sendMessage`. |
124
237
  | `422 provider_unsupported` | The number cannot send that. | Check the `capabilities` of the number. |
125
238
  | `422 device_unavailable` | The number is not connected. | Reconnect it. |
126
239
  | `429 rate_limited` | You went over the pace of your number. It carries `retryAfter`. | Wait those seconds and retry. |
@@ -128,36 +241,22 @@ const { message } = await funnelchat.templates.send(3, '45', {
128
241
 
129
242
  `isSendRuleError(error.code)` tells you whether the error is a rejected send; `SEND_ERROR_CODES` has all of them, with their HTTP status. A send is **never retried automatically** (see [Retries](#retries)).
130
243
 
131
- ## Forms
132
-
133
- A WhatsApp form is built, published on one or more numbers and sent to a contact. There are two ids: `id` is one version; `family_id` is the form itself, the one you send it with and the one that comes back as `form.id` in `whatsapp_form.completed`.
134
-
135
- ```ts
136
- const { data: draft } = await funnelchat.forms.create({ device_id: 3, template_key: 'lead' }); // Contacts and conversations → Edit (conversations:write)
137
- await funnelchat.forms.publish(draft.id);
138
-
139
- const { message } = await funnelchat.messages.sendForm(2001, {
140
- form_id: draft.family_id,
141
- device_id: 3,
142
- text: 'How did it go?',
143
- client_reference: 'survey-8841',
144
- });
145
- ```
244
+ #### A message to a group
146
245
 
147
- The answers arrive by the `whatsapp_form.completed` webhook (`answers`: `key`, `label` and `value`) and are read with `forms.listResponses(familyId)`. `forms.templates()` lists the templates to start from; `forms.update(id, { definition, expected_updated_at })` saves a draft and answers `409 STALE` when someone saved it first.
246
+ `groups.sendMessage(uid, body)` answers `202` with `{ action_id, status: 'queued' }`. The body is `device_id` and `type`: `text` (with `text`, up to 4096 characters) or `image`, `video`, `audio` or `document` (with `media_url`, a public `https` URL that ends in an extension of the type; `caption` for an image or a video, `file_name` for a document). Polls, events, stickers, contacts and locations are not accepted. A QR number goes out at the pace of the scheduled messages; an official number checks the 24-hour window of the group and its quality first.
148
247
 
149
- ## Connect a number
248
+ `groups.retrieveAction(action_id)` brings `status` (`queued`, `running`, `completed` or `failed`), `reason` when it failed and `result.message.uid`, the id of the message in WhatsApp. Its rejections are in `GROUP_ACTION_ERROR_CODES`: `404 device_not_found`, `404 group_not_found`, `422 device_not_connected`, `422 device_not_group_admin` (a QR number that is not admin), `422 device_not_group_number` (an official group of another number), `422 window_closed`, `422 number_restricted`, `422 unsupported_type`, `429 too_many_pending_actions` (50 open actions on the number) and `404 action_not_found` when reading.
150
249
 
151
- > **In production, except the official sign-up by link.** Creating QR numbers and connect links by QR work in production. Connect links with `type: 'meta'` are not available in production yet: they answer `503 meta_connect_disabled` until the Funnelchat domain is registered with Meta. The published spec does not describe these routes yet; see [Pending of the spec](#pending-of-the-spec).
250
+ ### Connect a number
152
251
 
153
- A number belongs to the account of the key and takes a slot of its plan, as in the panel. Creating numbers, showing their QR code and creating or revoking connect links needs **WhatsApp numbers → Create, connect and delete** (`accounts:operate`); reading links, **WhatsApp numbers → View** (`accounts:read`). Tag each number with your own `external_reference` (for example the id of your customer): it comes back in the number, in the links and in every `device.*` webhook.
252
+ A number belongs to the workspace of the key and takes a slot of the plan, as in the panel. Tag each number with your own `external_reference` (for example the id of your customer): it comes back in the number, in the links and in every `device.*` webhook.
154
253
 
155
254
  **A connect link** is a single-use page on the Funnelchat domain. Send your customer there: the number is created when they finish.
156
255
 
157
256
  ```ts
158
257
  const { data: link } = await funnelchat.connectLinks.create(
159
258
  {
160
- type: 'qr', // a number by QR code; or 'meta' (not in production yet)
259
+ type: 'qr', // a number by QR code
161
260
  country: 'br', // the country of the phone that will scan the QR
162
261
  external_reference: 'customer-81',
163
262
  return_url: 'https://your-app.example/whatsapp/done', // optional, https only
@@ -168,11 +267,10 @@ const { data: link } = await funnelchat.connectLinks.create(
168
267
  console.log(link.url); // https://api.funnelchat.com/connect/… — it carries the token and is shown only now
169
268
  ```
170
269
 
270
+ - Once your customer opens the page, they have 10 minutes to scan the QR.
171
271
  - `country` (only `qr`, two letters, optional) is the country of the phone that will scan the QR: the WhatsApp session is opened from there, so send it when you know it. It does not go with `device_id` (a reconnection creates nothing), and the link returns it, lowercase.
172
- - `type: 'meta'` (an official WhatsApp Cloud number, **not in production yet**: `503 meta_connect_disabled`) takes `allowed_connection_types: ['coexistence', 'dedicated']`, one or both; with both, your customer chooses on the page.
173
- - `provision_us_number: true` (only `meta`) also offers a US number of the pool. A pool number is always dedicated: it needs `dedicated` in `allowed_connection_types` and does not go with `device_id`; otherwise the API answers `422 invalid_request` (the SDK does not check it for you).
272
+ - `type: 'meta'` (an official WhatsApp Cloud number) takes `allowed_connection_types: ['coexistence', 'dedicated']`, one or both. In production it answers `503 meta_connect_disabled` for now: official numbers are connected from the panel. `provision_us_number: true` (only `meta`) also offers a US number of the pool; it needs `dedicated` and does not go with `device_id` (`422 invalid_request`).
174
273
  - `device_id` reconnects a number you already have, without taking a new slot; `same_number_as` (only `qr`) adds QR to a number in coexistence.
175
- - A Meta link tolerates retries: after a failure of Facebook or Meta it stays `pending`, so your customer can try again on the page; the fifth failed attempt turns it `failed` with `facebook_auth_failed` or `meta_error`. `quota_exceeded`, `trial_number_blocked` and `number_not_owned` close it at the first attempt.
176
274
  - You learn the result three ways: the `device.connected` webhook (with `connect_link_id` and `external_reference`), the `return_url` (`?connect_link_id=…&status=completed|failed&device_id=…&error_code=…`), or reading the link:
177
275
 
178
276
  ```ts
@@ -202,10 +300,9 @@ for await (const number of funnelchat.devices.list({ external_reference: 'custom
202
300
  ```
203
301
 
204
302
  - **Ask for the QR every 5 seconds and have it scanned right away.** WhatsApp changes the code every few seconds and an old one no longer links ("can't link the device now"): replace the image with the latest one on each call, and never store, cache or forward a QR.
205
- - `country` (two letters, optional) is the country of the phone that will scan the QR: the WhatsApp session is opened from there.
206
303
  - An account has at most one QR number waiting to be scanned: creating another answers `409 pending_number_exists`, with that number's `device_id` in `error.body`. Show its QR instead, or delete it.
207
304
  - `external_reference` changes with `devices.update(id, { external_reference })`.
208
- - `devices.delete(id)` deletes a number and frees its slot. It needs **WhatsApp numbers → Create, connect and delete** (`accounts:operate`) and is irreversible. An official number stops working through Funnelchat but stays registered with Meta; a QR one logs out. It sends `device.deleted`.
305
+ - `devices.delete(id)` deletes a number and frees its slot. It is irreversible. An official number stops working through Funnelchat but stays registered with Meta; a QR one logs out. It sends `device.deleted`.
209
306
  - `devices.list(query, options)` takes the query first, like every list. The signature of 0.1.2, `devices.list(options)`, still works.
210
307
 
211
308
  **`idempotencyKey`** (both creates, 1 to 255 characters) is sent as `Idempotency-Key` and kept 24 hours. The same key with the same body answers the same response without creating another number or link, so after a timeout or a `5xx` you can repeat the call with it. Only a success is replayed, with the `Idempotent-Replayed: true` header (see it with `.withResponse()`); a rejection is evaluated again. On their own, creates follow the rule of writes (see [Retries](#retries)): they are retried only on a `429` or `503`, which guarantee nothing was created.
@@ -218,10 +315,12 @@ for await (const number of funnelchat.devices.list({ external_reference: 'custom
218
315
  | `device.disconnected` | `device`, `disconnected_at`, `connect_link_id`, `reason`: `qr_logged_out`, `meta_account_not_registered`, `shadow_ban_protection`, `provider_error`, `disconnected_by_user` or `unknown`. |
219
316
  | `device.deleted` | `device`, `deleted_at`, `connect_link_id`. |
220
317
  | `device.health_changed` | `device`, `previous_state`, `state` (`available`, `limited`, `blocked`, `unknown`), `changed_at`, `connect_link_id`. Official numbers, only when the state changes. |
318
+ | `device.quality_changed` | `device`, `previous`, `current` (`GREEN`, `YELLOW`, `RED`), `changed_at`, `connect_link_id`. Official numbers. |
319
+ | `device.messaging_limit_changed` | `device`, `previous`, `current` (`TIER_250` … `TIER_UNLIMITED`), `changed_at`, `connect_link_id`. Official numbers. |
320
+ | `device.display_name_reviewed` | `device`, `decision` (`approved` or `rejected`), `previous_status`, `status`, `verified_name`, `rejection_reason`, `reviewed_at`, `connect_link_id`. |
221
321
 
222
322
  - `device` is `{ id, name, number, connection, external_reference }`, where `connection` is `qr`, `coexistence`, `dedicated` or `null`.
223
- - `connect_link_id`: in `device.connected`, the connect link of that connection; in the other three, the link that last connected the number. `null` when it did not come from a link.
224
- - What these events add to the ones production sends today (`connection`, `external_reference`, `connect_link_id` and `reason`) is optional in the types: it is missing until the API that serves it is deployed. Handle `undefined`.
323
+ - `connect_link_id`: in `device.connected`, the connect link of that connection; in the others, the link that last connected the number. `null` when it did not come from a link.
225
324
 
226
325
  **When it is refused** (`NUMBER_ERROR_CODES` and `IDEMPOTENCY_ERROR_CODES`):
227
326
 
@@ -229,13 +328,14 @@ for await (const number of funnelchat.devices.list({ external_reference: 'custom
229
328
  |---|---|
230
329
  | `403 numbers_not_included` | The plan does not include numbers. |
231
330
  | `403 number_limit_reached` | Every number of the plan is in use (`limit`, `used`, `pending_device_ids` in the body). |
331
+ | `403 connect_not_allowed` | The account cannot connect numbers now. |
232
332
  | `409 pending_number_exists` | A QR number is already waiting to be scanned; the body brings its `device_id`. |
233
333
  | `422 qr_requires_coexistence` | QR can only be added to an official number in coexistence. |
234
334
  | `422 method_already_present` | The number already has that method; the body brings its `device_id`. |
235
335
  | `422 invalid_reference_device` | `same_number_as` or `device_id` is not a number of yours that can take it. |
236
336
  | `422 connection_type_mismatch` | A link with `device_id` of a number that connects another way than `type`. |
237
337
  | `429 too_many_open_links` | 20 connect links are open. Revoke some. |
238
- | `503 meta_connect_disabled` | Official numbers cannot be connected by link yet, as in production today. QR links work. |
338
+ | `503 meta_connect_disabled` | Official numbers cannot be connected by link yet. QR links work. |
239
339
  | `409 link_not_pending` | Revoking a link that already ended. |
240
340
  | `422 idempotency_key_mismatch` | The key was used with another body. |
241
341
  | `422 invalid_idempotency_key` | The key is not 1 to 255 characters. |
@@ -243,9 +343,9 @@ for await (const number of funnelchat.devices.list({ external_reference: 'custom
243
343
  | `422 provider_unsupported` | `devices.qr` on an official number. |
244
344
  | `409` on `devices.qr` | The number cannot connect now: `trial_number_blocked`, `number_suspended_by_plan`, `device_inactive`, `provisioning_quota_exceeded`. |
245
345
 
246
- `NUMBER_ERROR_CODES` lists the rest (`connect_not_allowed`, `payment_required`, `provider_error`…), with their status. `isNumberQuotaError(error.code)` tells you whether it was the quota of the plan (the first five). `too_many_open_links` and `meta_connect_disabled` are never retried automatically.
346
+ `NUMBER_ERROR_CODES` lists the rest (`payment_required`, `provider_error`…), with their status. `isNumberQuotaError(error.code)` tells you whether it was the quota of the plan (`numbers_not_included`, `number_limit_reached`, `pending_number_exists`, `qr_requires_coexistence`, `method_already_present`). `too_many_open_links` and `meta_connect_disabled` are never retried automatically.
247
347
 
248
- ## Receive webhooks with Express
348
+ ### Webhooks
249
349
 
250
350
  Verify the signature over the **raw body**, exactly as it arrived. If you parse it and serialize it again, the signature changes: use `express.raw`, not `express.json`.
251
351
 
@@ -280,8 +380,8 @@ function handle(event) {
280
380
  case 'message.status_changed':
281
381
  console.log(event.data.message_id, event.data.status, event.data.client_reference);
282
382
  break;
283
- case 'whatsapp_form.completed':
284
- console.log(event.data.form.id, event.data.answers);
383
+ case 'group_participant.joined':
384
+ console.log(event.data.group.uid, event.data.participant.identity);
285
385
  break;
286
386
  default:
287
387
  // A type your code does not know yet: it is not an error, answer 2xx anyway.
@@ -289,28 +389,29 @@ function handle(event) {
289
389
  }
290
390
  ```
291
391
 
292
- - `constructEvent` verifies the signature and returns the typed event: with `event.type`, TypeScript knows the shape of `event.data`. When something fails, it throws `WebhookVerificationError` with `reason`: `missing_header`, `malformed_header`, `timestamp_out_of_tolerance`, `signature_mismatch`, `invalid_body`, `invalid_json`…
392
+ - `constructEvent` verifies the signature and returns the typed event: with `event.type`, TypeScript knows the shape of `event.data`, and every `data` has `workspace_id`. When something fails, it throws `WebhookVerificationError` with `reason`: `missing_header`, `malformed_header`, `timestamp_out_of_tolerance`, `signature_mismatch`, `invalid_body`, `invalid_json`…
293
393
  - To just get a yes or no, `Funnelchat.webhooks.verify(rawBody, signatureHeader, secret)` returns `true` or `false` and never throws.
294
394
  - Both reject a signature whose `t` is more than 300 seconds away from your clock. To change it: `{ toleranceSeconds: 600 }` as the fourth argument.
295
395
  - The algorithm is the portal's: `X-Funnelchat-Signature: t=<unix>,v1=<hex>`, where `v1` is the HMAC-SHA256, with the full secret, of `"<t>." + raw body`, compared in constant time; there can be several `v1`. The tests use the portal's test vectors.
296
396
  - `webhookEvents` lists the types the SDK knows, with their status (`live` or `planned`).
297
397
 
298
- ### Subscribe
398
+ #### Subscribe
299
399
 
300
400
  ```ts
301
401
  const subscription = await funnelchat.webhooks.create({
302
402
  url: 'https://your-server.example/hooks/funnelchat',
303
- events: ['message.received', 'message.status_changed', 'whatsapp_form.completed'],
403
+ events: ['message.received', 'message.status_changed', 'device.connected', 'device.disconnected', 'group_participant.joined', 'group_participant.left'],
304
404
  description: 'My integration',
305
405
  });
306
406
  // Store subscription.id and subscription.secret: the secret is shown only once.
307
407
  ```
308
408
 
309
- - Permissions: **Notifications (webhooks) → View** (`webhooks:read`) to read and **Notifications (webhooks) → Manage** (`webhooks:write`) for the rest. Also, a key can only subscribe to events of the areas it can read: **Contacts and conversations → View** (`conversations:read`), **Groups and campaigns → View** (`communities:read`) and **WhatsApp numbers → View** (`accounts:read`). Asking for others is a `422` on `events`.
310
- - Subscriptions belong to the account: `list()` also brings the ones of other integrations. To change the URL use `update(id, …)` (the secret stays). `test(id)` sends a signed `webhook.test` right now and `listDeliveries(id)` is the history.
409
+ - Name every event you want: `message.status_changed` too. `events` is typed with the names the API accepts.
410
+ - Permissions: **Notifications (webhooks) → View** to read and **Notifications (webhooks) → Manage** for the rest. Also, a key can only subscribe to events of the areas it can read: **Contacts and conversations → View**, **Groups and campaigns → View** and **WhatsApp numbers → View**. Asking for others is a `422` on `events`.
411
+ - A subscription belongs to the workspace of the key that created it and receives only that workspace's events; `list()` brings every subscription of the workspace, also the ones of other integrations. To change the URL use `update(id, …)` (the secret stays). `test(id)` sends a signed `webhook.test` right now and `listDeliveries(id)` is the history.
311
412
  - `rotateSecret(id)` is **not open to API keys** (`403 route_not_available`): rotate the secret from the panel.
312
413
 
313
- ## Pagination
414
+ ### Pagination
314
415
 
315
416
  `list…` methods return a lazy page: nothing is requested until you use it.
316
417
 
@@ -330,7 +431,7 @@ while (page.hasNextPage()) {
330
431
 
331
432
  The SDK understands the three ways the API paginates: by page number (`meta.current_page` and `meta.last_page`), by cursor (`meta.next_cursor`) and lists that come whole in an array. The page size is asked with the parameter of each route (`perPage`, `per_page` or `pageSize`, as the spec says).
332
433
 
333
- ## Errors
434
+ ### Errors
334
435
 
335
436
  Everything the SDK throws extends `FunnelchatError`. When the API answers with an error, it is an `APIError`:
336
437
 
@@ -367,8 +468,9 @@ The errors of the API entry and of access control apply to any route (`ACCESS_ER
367
468
  | Error | What happened |
368
469
  |---|---|
369
470
  | `401 unauthenticated` | The key is missing or not valid. |
370
- | `403 permission_denied` | The key lacks the permission of the route. `message` names it by its literal (such as `accounts:operate`), which the panel shows as the tooltip of each checkbox. |
471
+ | `403 permission_denied` | The key lacks the permission of the route, such as **WhatsApp numbers → Create, connect and delete**: give the key that permission in the panel. |
371
472
  | `403 route_not_available` | The route is not open to API keys (or it is turned off). |
473
+ | `404` | The resource does not exist or is of another workspace than the key's. |
372
474
  | `404 route_not_found` | The route does not exist in the public API. |
373
475
  | `429 daily_cap_exceeded` | The daily call cap of the key was reached. It carries `Retry-After`. |
374
476
  | `500 edge_misconfigured` | A problem on our side: report it with the `requestId`. |
@@ -381,11 +483,11 @@ The errors of the API entry and of access control apply to any route (`ACCESS_ER
381
483
  const { data, requestId, response } = await funnelchat.contacts.retrieve(2001).withResponse();
382
484
  ```
383
485
 
384
- ## Retries
486
+ ### Retries
385
487
 
386
488
  - Reads and writes that give the same result when repeated (`GET`, `PUT`, `DELETE`) are retried on a `429`, a `5xx` or a network error (also when `timeoutMs` ran out), up to `maxRetries` times (2 by default).
387
489
  - Other writes (`POST`, `PATCH`) are retried **only when the API guarantees they were not applied**: `429` and `503`. A `500`, `502` or `504`, a timeout or a dropped connection can come after the write was applied, so the error is thrown right away: read the state before repeating it.
388
- - **A send is never retried automatically**: the `POST`s that need **Contacts and conversations → Send** (`conversations:send`): messages, forms, cards, forwards and templates. If a `429 rate_limited` sent nothing and you want the SDK to retry for you, ask for it in that call with `{ retrySend: true }`: it then follows the rule of writes. `mac_lockout_24h` and `mac_limit_reached` are never retried.
490
+ - **A send is never retried automatically**: the `POST`s that need a **Send** permission (messages, forms, cards, forwards, templates and messages to a group). If a `429 rate_limited` sent nothing and you want the SDK to retry for you, ask for it in that call with `{ retrySend: true }`: it then follows the rule of writes. `mac_lockout_24h`, `mac_limit_reached` and `too_many_pending_actions` are never retried.
389
491
  - With `Retry-After`, the wait is exactly what it asks for. If it asks for more than 60 seconds, there is no wait: the error is thrown right away with its `retryAfter` and you decide. Without `Retry-After`, the wait is exponential: 0.5 s, 1 s, 2 s… up to 8 s, with some jitter.
390
492
  - Per call you can change `maxRetries`, `timeoutMs` and `signal`:
391
493
 
@@ -393,7 +495,7 @@ const { data, requestId, response } = await funnelchat.contacts.retrieve(2001).w
393
495
  await funnelchat.tags.list({}, { maxRetries: 0, timeoutMs: 5000, signal: AbortSignal.timeout(10_000) });
394
496
  ```
395
497
 
396
- ## Any route
498
+ ### Any route
397
499
 
398
500
  `request` calls any route. With a route of the spec, the path parameters, the query, the body and the response are typed:
399
501
 
@@ -415,27 +517,26 @@ The query is built the way Laravel reads it: arrays as `key[]=a&key[]=b`, object
415
517
 
416
518
  The types of the spec are exported too: `paths`, `components`, `operations`, and the shorthands `Body<'/api/v1/tags', 'POST'>`, `Query<'/api/v1/contacts'>`, `ResponseBody<…>`. The `routes` table has every operation with its area and its permission.
417
519
 
418
- ## Test against the sandbox
520
+ ### Test against the sandbox
419
521
 
420
- [funnelchat-sandbox](https://github.com/Funnelchat20/funnelchat-sandbox) imitates the API on your machine: sends with their status cycle, signed webhooks and the WhatsApp rules, without a real account. Point the client at it:
522
+ The sandbox imitates the API on your machine: sends with their status cycle, signed webhooks and the WhatsApp rules, without a real account. Start it with `npx @funnelchat-hq/sandbox` and point the client at it:
421
523
 
422
524
  ```ts
423
525
  const funnelchat = new Funnelchat({ apiKey: '1|fc_live_sandbox', baseUrl: 'http://localhost:4010' });
424
526
  ```
425
527
 
426
- ## Pending of the spec
528
+ ### Pending of the spec
427
529
 
428
- The published spec does not describe everything the API in production already has yet. What is missing lives in a separate layer, `spec/pending.json`, which generation adds to the types **only while the spec does not bring it**; each entry cites where it came from:
530
+ The types come from the full unified spec of the developer portal (`spec/funnelchat.json`; where it came from, in `spec/procedencia.json`: developers-portal c90565e4 of 04-11-2026, with the areas at accounts v1.5.97, conversations v1.19.7 and communities v2.35.5). What production has and that spec does not describe, or describes otherwise than production answers, lives in a separate layer, `spec/pending.json`, which generation applies **only while the spec does not bring it**, or still says exactly what the entry corrects; each entry cites the production code it was read in:
429
531
 
430
- - what sends answer (the queued message), `messages.get`, `client_reference`, `file_type` and `file_name`;
431
- - forms and `messages.sendForm`;
432
- - the `capabilities` of numbers;
433
- - webhook subscriptions and the events the spec does not bring (`message.status_changed` with its shape, `whatsapp_form.completed`, the group ones and the account ones);
434
- - creating and connecting numbers (`devices.create`, `devices.qr`, `country`, `external_reference`, `connectLinks`, `Idempotency-Key`) and the `device.*` events: in production (except `type: 'meta'` links), typed from the API of accounts until its spec is published.
532
+ - a message to a group and its action (`groups.sendMessage`, `groups.retrieveAction`, `group_action.completed`): the spec was built with the group actions off;
533
+ - the events of the account (`device.*`, billing, subscriptions, monthly active contacts) and some of groups (`group.message_received`, `group.reaction_added`, `group.full`, `group_action.failed`, the join requests): the API publishes them in its catalog (`webhooks.events()`), not in the spec;
534
+ - WhatsApp forms (except sending one) and `webhooks.rotateSecret`;
535
+ - where the spec contradicts production: what a send answers (`{ message }`, never `null`), the body of `devices.qr`, the items of `templates.list`, the answer of `webhooks.update`, and the events the spec only shows by one example (`message.received`, `message.status_changed`, `whatsapp_form.completed`, `group_participant.joined` and `.left`).
435
536
 
436
- `src/pending/` has what is not OpenAPI (`device_id` in `message.received`) and `src/core/error-codes.ts` the error codes.
537
+ `src/pending/` has what is not OpenAPI (the `workspace_id` of every event) and `src/core/error-codes.ts` the error codes.
437
538
 
438
- ## Development
539
+ ### Development
439
540
 
440
541
  ```bash
441
542
  npm install
@@ -454,12 +555,12 @@ To release, push a `vX.Y.Z` tag on `main` matching the `version` in `package.jso
454
555
  Everything generated comes from one script:
455
556
 
456
557
  ```bash
457
- npm run generate -- --spec <path>/funnelchat.json # with a new unified spec of the portal
458
- npm run generate # with the one in spec/, for example after editing spec/pending.json
558
+ npm run generate -- --spec <path>/funnelchat-completa.json # with a new full unified spec of the portal
559
+ npm run generate # with the one in spec/, for example after editing spec/pending.json
459
560
  ```
460
561
 
461
- 1. With `--spec`, it copies the spec to `spec/funnelchat.json` and writes `spec/procedencia.json`: repository, branch, commit, date and `sha256` of the file.
462
- 2. It adds `spec/pending.json`, but only what the spec does not bring, and lists what it already brings so you can delete it from there. An `operationPatches` entry without `while` adds body properties or `parameters` (such as a query filter) one by one, only while the operation lacks them.
562
+ 1. With `--spec`, it copies the spec to `spec/funnelchat.json` and writes `spec/procedencia.json`: repository, branch, commit, date and `sha256` of the file. The portal builds its full spec in `generated/specs/funnelchat-completa.json`, which git ignores, so a copy of it says where it came from on the command line: `--expect-sha256 <hex>`, `--source repository=… --source branch=… --source commit=… --source path=…` and `--domain <area>=<sha>@<branch>@<snapshot_at>` for each area; `spec/procedencia.json` marks those fields as declared. Never generate from the published `/specs/`: it only carries what the portal shows.
563
+ 2. It adds `spec/pending.json`, but only what the spec does not bring, and lists what it already brings so you can delete it from there. An `operationPatches` entry without `while` adds body properties or `parameters` (such as a query filter) one by one, only while the operation lacks them. A `responsePatches` entry, and an event with `x-fc-replace-while`, replace the spec's only while it still matches: a key starting with `/` is a JSON Pointer into the operation or the event (`/responses/200/content/application~1json/schema`), so the entry stops applying, and is listed, as soon as the spec changes that place.
463
564
  3. It writes `src/generated/openapi.ts` (with [openapi-typescript](https://openapi-ts.dev)), `src/generated/routes.ts` (every operation, with area and permission) and `src/generated/events.ts` (the event catalog and the shape of the `data` of each one).
464
565
 
465
566
  ```