@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 +190 -89
- package/dist/index.cjs +105 -39
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8445 -6160
- package/dist/index.d.ts +8445 -6160
- package/dist/index.js +105 -40
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,36 +1,131 @@
|
|
|
1
1
|
# funnelchat-node
|
|
2
2
|
|
|
3
|
-
Official Node.js and TypeScript SDK for the [Funnelchat
|
|
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
|
-
-
|
|
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
|
|
12
|
+
## First use
|
|
13
|
+
|
|
14
|
+
### 1. Create your key
|
|
18
15
|
|
|
19
|
-
|
|
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(
|
|
21
|
+
const funnelchat = new Funnelchat(); // reads FUNNELCHAT_API_KEY
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### 2. Connect a number
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
- `
|
|
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
|
-
|
|
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
|
-
|
|
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. |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
+
### Connect a number
|
|
152
251
|
|
|
153
|
-
A number belongs to the
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (`
|
|
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
|
-
|
|
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 '
|
|
284
|
-
console.log(event.data.
|
|
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
|
-
|
|
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', '
|
|
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
|
-
-
|
|
310
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 **
|
|
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
|
-
|
|
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
|
-
|
|
520
|
+
### Test against the sandbox
|
|
419
521
|
|
|
420
|
-
|
|
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
|
-
|
|
528
|
+
### Pending of the spec
|
|
427
529
|
|
|
428
|
-
The
|
|
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
|
-
-
|
|
431
|
-
-
|
|
432
|
-
-
|
|
433
|
-
-
|
|
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 (`
|
|
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
|
-
|
|
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
|
|
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
|
```
|