@funnelchat-hq/sdk 0.2.2 → 0.3.1
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 +187 -89
- package/dist/index.cjs +114 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10183 -7118
- package/dist/index.d.ts +10183 -7118
- package/dist/index.js +114 -35
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,36 +1,129 @@
|
|
|
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
|
+
One call, from your number to a phone (with country code, digits only):
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const { message } = await funnelchat.messages.sendDirect({ device_id: device.id, phone: '5491100000000', text: 'Hi Ana!' });
|
|
47
|
+
console.log(message.id, message.contact_id, message.status); // 1002 2001 'queued'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
If that phone is not a contact yet, it is created in the workspace of your key; `message.contact_id` is its id. 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, so the first message to a new contact is an [approved template](#templates).
|
|
51
|
+
|
|
52
|
+
### 4. Know when it was read
|
|
53
|
+
|
|
54
|
+
Subscribe once to `message.status_changed` and verify each delivery with your secret:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const subscription = await funnelchat.webhooks.create({
|
|
58
|
+
url: 'https://your-app.example/hooks/funnelchat',
|
|
59
|
+
events: ['message.status_changed'],
|
|
60
|
+
});
|
|
61
|
+
// Keep subscription.secret (FUNNELCHAT_WEBHOOK_SECRET below): it is shown only once.
|
|
62
|
+
|
|
63
|
+
app.post('/hooks/funnelchat', express.raw({ type: 'application/json' }), (req, res) => {
|
|
64
|
+
const event = Funnelchat.webhooks.constructEvent(req.body, req.get('X-Funnelchat-Signature'), process.env.FUNNELCHAT_WEBHOOK_SECRET!);
|
|
65
|
+
if (event.type === 'message.status_changed' && event.data.status === 'read') {
|
|
66
|
+
console.log(`Message ${event.data.message_id} was read`);
|
|
67
|
+
}
|
|
68
|
+
res.sendStatus(200);
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The status goes `sent`, `delivered`, `read` (or `failed`, with `error`). You can also ask for it: `funnelchat.messages.get(contactId, messageId)`, with `message.contact_id` and `message.id`. More in [Webhooks](#webhooks).
|
|
73
|
+
|
|
74
|
+
## Receive messages
|
|
75
|
+
|
|
76
|
+
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:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
if (event.type === 'message.received') {
|
|
80
|
+
const { message, contact, device_id } = event.data;
|
|
81
|
+
console.log(contact.phone, message.text ?? message.type); // text is null for a file without caption
|
|
82
|
+
|
|
83
|
+
if (device_id && contact.id) {
|
|
84
|
+
await funnelchat.messages.sendDirect({ device_id, contact_id: contact.id, text: 'Got it, thanks!' });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Groups
|
|
90
|
+
|
|
91
|
+
List the groups of your numbers and send a message to one by its `uid`:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
for await (const group of funnelchat.groups.list()) {
|
|
95
|
+
console.log(group.uid, group.name); // '120363000000000001@g.us', 'Customers'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { action_id } = await funnelchat.groups.sendMessage('120363000000000001@g.us', {
|
|
99
|
+
device_id: device.id, // a QR number must be admin of the group
|
|
100
|
+
type: 'text',
|
|
101
|
+
text: 'Hi everyone!',
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- 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`.
|
|
106
|
+
- 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.
|
|
107
|
+
- Permissions: **Groups and campaigns → View** to read and **Groups and campaigns → Send** to send.
|
|
108
|
+
|
|
109
|
+
## Workspaces
|
|
110
|
+
|
|
111
|
+
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.
|
|
112
|
+
|
|
113
|
+
Every webhook event says which workspace it belongs to, and a subscription only receives the events of its own workspace:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
const event = Funnelchat.webhooks.constructEvent(rawBody, signature, secret);
|
|
117
|
+
console.log(event.type, event.data.workspace_id); // 'message.received', 37
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
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.
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Reference
|
|
125
|
+
|
|
126
|
+
### Client options
|
|
34
127
|
|
|
35
128
|
| Option | What it is | Default |
|
|
36
129
|
|---|---|---|
|
|
@@ -42,29 +135,43 @@ Client options:
|
|
|
42
135
|
| `defaultHeaders` | Headers sent with every request. | — |
|
|
43
136
|
| `fetch` | Another `fetch` implementation. | Node's |
|
|
44
137
|
|
|
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.
|
|
138
|
+
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
139
|
|
|
47
|
-
|
|
140
|
+
### The pieces
|
|
48
141
|
|
|
49
142
|
| Resource | What it has |
|
|
50
143
|
|---|---|
|
|
144
|
+
| `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. |
|
|
145
|
+
| `funnelchat.connectLinks` | `create`, `get`, `list`, `revoke`: a page where your customer connects a number. |
|
|
51
146
|
| `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
147
|
| `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
148
|
| `funnelchat.templates` | `list`, `create`, `retrieve`, `update`, `quickReplyButtons`, `send`, `delete`. |
|
|
149
|
+
| `funnelchat.groups` | `list`, `retrieve`, `listByDevice`, participants and their joins and leaves, messages, `sendMessage` and `retrieveAction`. |
|
|
150
|
+
| `funnelchat.webhooks` | Subscriptions (`list`, `create`, `retrieve`, `update`, `delete`, `test`, `listDeliveries`), the catalog (`events`), `verify` and `constructEvent`. |
|
|
151
|
+
| `funnelchat.conversations` | `list`, `update`, counters, mark everything as read or unread. |
|
|
152
|
+
| `funnelchat.forms` | WhatsApp forms: `list`, `templates`, `create`, `retrieve`, `update`, `delete`, `createVersion`, `sync`, `publish`, numbers, `versions`, answers and `metrics`. |
|
|
56
153
|
| `funnelchat.tags` | `list`, `create`, `retrieve`, `update`, `delete`, `listContacts`. |
|
|
57
154
|
| `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
155
|
|
|
63
156
|
Parameters, bodies and responses are typed from the spec. For any other route, see [Any route](#any-route).
|
|
64
157
|
|
|
65
|
-
|
|
158
|
+
### Permissions
|
|
66
159
|
|
|
67
|
-
|
|
160
|
+
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**.
|
|
161
|
+
|
|
162
|
+
| To | You need |
|
|
163
|
+
|---|---|
|
|
164
|
+
| List numbers, read their status and connect links | **WhatsApp numbers → View** |
|
|
165
|
+
| Create numbers, show their QR, create and revoke connect links, delete numbers | **WhatsApp numbers → Create, connect and delete** |
|
|
166
|
+
| Create contacts | **Contacts and conversations → Edit** |
|
|
167
|
+
| Send messages and templates | **Contacts and conversations → Send** |
|
|
168
|
+
| Read groups | **Groups and campaigns → View** |
|
|
169
|
+
| Send to a group | **Groups and campaigns → Send** |
|
|
170
|
+
| Webhook subscriptions | **Notifications (webhooks) → View** to read, **Notifications (webhooks) → Manage** for the rest, plus **View** of the area of each event |
|
|
171
|
+
|
|
172
|
+
### Send a message
|
|
173
|
+
|
|
174
|
+
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
175
|
|
|
69
176
|
**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
177
|
|
|
@@ -82,10 +189,10 @@ if (window.is_open) {
|
|
|
82
189
|
}
|
|
83
190
|
```
|
|
84
191
|
|
|
192
|
+
- **By phone, without `contact_id`:** `sendDirect` and `templates.send` take `phone` (7 to 15 digits, with country code) and create the contact in the workspace of your key when it does not exist yet, as `contacts.upsert` would; the contact stays even if a rule then stops the send. With an official number you can send `whatsapp_id` (Meta's id of the person, such as `US.13491208655302741918`) instead of `phone`; with a QR number that is a `422`. A phone that is a contact of another workspace of your account answers `404`, and nothing is created or sent. With `contact_id` too, `phone` or `whatsapp_id` must be that contact's: otherwise `422 recipient_mismatch`. Creating the contact takes **Contacts and conversations → Edit**, which **Send** includes.
|
|
85
193
|
- `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
194
|
- `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.
|
|
195
|
+
- `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 the recipient (`phone`, `whatsapp_id` or `contact_id`) in the body.
|
|
89
196
|
- 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
197
|
|
|
91
198
|
**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,17 +209,20 @@ await funnelchat.messages.send(2001, {
|
|
|
102
209
|
|
|
103
210
|
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
211
|
|
|
105
|
-
|
|
212
|
+
#### Templates
|
|
213
|
+
|
|
214
|
+
**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
215
|
|
|
107
216
|
```ts
|
|
108
217
|
const { message } = await funnelchat.templates.send(3, '45', {
|
|
109
|
-
phone: '5491100000000',
|
|
110
|
-
contact_id: 2001,
|
|
218
|
+
phone: '5491100000000', // a new phone becomes a contact, as with sendDirect
|
|
111
219
|
template_variables: { body: ['Lucía'] }, // the body variables, in order
|
|
112
220
|
});
|
|
113
221
|
```
|
|
114
222
|
|
|
115
|
-
|
|
223
|
+
#### When a send is stopped
|
|
224
|
+
|
|
225
|
+
A send that breaks a rule is neither sent nor stored: you get an error with its `code`.
|
|
116
226
|
|
|
117
227
|
| Error | What happened | What to do |
|
|
118
228
|
|---|---|---|
|
|
@@ -120,7 +230,7 @@ const { message } = await funnelchat.templates.send(3, '45', {
|
|
|
120
230
|
| `422 antiblock_duplicate_content` | The same content repeated to the same contact. | Change the text or wait. |
|
|
121
231
|
| `422 antiblock_unanswered_limit` | Too many messages in a row to someone who does not answer. | Wait until they answer. |
|
|
122
232
|
| `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. |
|
|
233
|
+
| `422 recipient_unsupported` | The contact is a group, a broadcast list or a channel. | Write to a group with `groups.sendMessage`. |
|
|
124
234
|
| `422 provider_unsupported` | The number cannot send that. | Check the `capabilities` of the number. |
|
|
125
235
|
| `422 device_unavailable` | The number is not connected. | Reconnect it. |
|
|
126
236
|
| `429 rate_limited` | You went over the pace of your number. It carries `retryAfter`. | Wait those seconds and retry. |
|
|
@@ -128,36 +238,22 @@ const { message } = await funnelchat.templates.send(3, '45', {
|
|
|
128
238
|
|
|
129
239
|
`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
240
|
|
|
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
|
|
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
|
-
```
|
|
241
|
+
#### A message to a group
|
|
146
242
|
|
|
147
|
-
|
|
243
|
+
`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
244
|
|
|
149
|
-
|
|
245
|
+
`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
246
|
|
|
151
|
-
|
|
247
|
+
### Connect a number
|
|
152
248
|
|
|
153
|
-
A number belongs to the
|
|
249
|
+
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
250
|
|
|
155
251
|
**A connect link** is a single-use page on the Funnelchat domain. Send your customer there: the number is created when they finish.
|
|
156
252
|
|
|
157
253
|
```ts
|
|
158
254
|
const { data: link } = await funnelchat.connectLinks.create(
|
|
159
255
|
{
|
|
160
|
-
type: 'qr', // a number by QR code
|
|
256
|
+
type: 'qr', // a number by QR code
|
|
161
257
|
country: 'br', // the country of the phone that will scan the QR
|
|
162
258
|
external_reference: 'customer-81',
|
|
163
259
|
return_url: 'https://your-app.example/whatsapp/done', // optional, https only
|
|
@@ -168,11 +264,10 @@ const { data: link } = await funnelchat.connectLinks.create(
|
|
|
168
264
|
console.log(link.url); // https://api.funnelchat.com/connect/… — it carries the token and is shown only now
|
|
169
265
|
```
|
|
170
266
|
|
|
267
|
+
- Once your customer opens the page, they have 10 minutes to scan the QR.
|
|
171
268
|
- `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).
|
|
269
|
+
- `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
270
|
- `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
271
|
- 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
272
|
|
|
178
273
|
```ts
|
|
@@ -202,10 +297,9 @@ for await (const number of funnelchat.devices.list({ external_reference: 'custom
|
|
|
202
297
|
```
|
|
203
298
|
|
|
204
299
|
- **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
300
|
- 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
301
|
- `external_reference` changes with `devices.update(id, { external_reference })`.
|
|
208
|
-
- `devices.delete(id)` deletes a number and frees its slot. It
|
|
302
|
+
- `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
303
|
- `devices.list(query, options)` takes the query first, like every list. The signature of 0.1.2, `devices.list(options)`, still works.
|
|
210
304
|
|
|
211
305
|
**`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 +312,12 @@ for await (const number of funnelchat.devices.list({ external_reference: 'custom
|
|
|
218
312
|
| `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
313
|
| `device.deleted` | `device`, `deleted_at`, `connect_link_id`. |
|
|
220
314
|
| `device.health_changed` | `device`, `previous_state`, `state` (`available`, `limited`, `blocked`, `unknown`), `changed_at`, `connect_link_id`. Official numbers, only when the state changes. |
|
|
315
|
+
| `device.quality_changed` | `device`, `previous`, `current` (`GREEN`, `YELLOW`, `RED`), `changed_at`, `connect_link_id`. Official numbers. |
|
|
316
|
+
| `device.messaging_limit_changed` | `device`, `previous`, `current` (`TIER_250` … `TIER_UNLIMITED`), `changed_at`, `connect_link_id`. Official numbers. |
|
|
317
|
+
| `device.display_name_reviewed` | `device`, `decision` (`approved` or `rejected`), `previous_status`, `status`, `verified_name`, `rejection_reason`, `reviewed_at`, `connect_link_id`. |
|
|
221
318
|
|
|
222
319
|
- `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`.
|
|
320
|
+
- `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
321
|
|
|
226
322
|
**When it is refused** (`NUMBER_ERROR_CODES` and `IDEMPOTENCY_ERROR_CODES`):
|
|
227
323
|
|
|
@@ -229,13 +325,14 @@ for await (const number of funnelchat.devices.list({ external_reference: 'custom
|
|
|
229
325
|
|---|---|
|
|
230
326
|
| `403 numbers_not_included` | The plan does not include numbers. |
|
|
231
327
|
| `403 number_limit_reached` | Every number of the plan is in use (`limit`, `used`, `pending_device_ids` in the body). |
|
|
328
|
+
| `403 connect_not_allowed` | The account cannot connect numbers now. |
|
|
232
329
|
| `409 pending_number_exists` | A QR number is already waiting to be scanned; the body brings its `device_id`. |
|
|
233
330
|
| `422 qr_requires_coexistence` | QR can only be added to an official number in coexistence. |
|
|
234
331
|
| `422 method_already_present` | The number already has that method; the body brings its `device_id`. |
|
|
235
332
|
| `422 invalid_reference_device` | `same_number_as` or `device_id` is not a number of yours that can take it. |
|
|
236
333
|
| `422 connection_type_mismatch` | A link with `device_id` of a number that connects another way than `type`. |
|
|
237
334
|
| `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
|
|
335
|
+
| `503 meta_connect_disabled` | Official numbers cannot be connected by link yet. QR links work. |
|
|
239
336
|
| `409 link_not_pending` | Revoking a link that already ended. |
|
|
240
337
|
| `422 idempotency_key_mismatch` | The key was used with another body. |
|
|
241
338
|
| `422 invalid_idempotency_key` | The key is not 1 to 255 characters. |
|
|
@@ -243,9 +340,9 @@ for await (const number of funnelchat.devices.list({ external_reference: 'custom
|
|
|
243
340
|
| `422 provider_unsupported` | `devices.qr` on an official number. |
|
|
244
341
|
| `409` on `devices.qr` | The number cannot connect now: `trial_number_blocked`, `number_suspended_by_plan`, `device_inactive`, `provisioning_quota_exceeded`. |
|
|
245
342
|
|
|
246
|
-
`NUMBER_ERROR_CODES` lists the rest (`
|
|
343
|
+
`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
344
|
|
|
248
|
-
|
|
345
|
+
### Webhooks
|
|
249
346
|
|
|
250
347
|
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
348
|
|
|
@@ -280,8 +377,8 @@ function handle(event) {
|
|
|
280
377
|
case 'message.status_changed':
|
|
281
378
|
console.log(event.data.message_id, event.data.status, event.data.client_reference);
|
|
282
379
|
break;
|
|
283
|
-
case '
|
|
284
|
-
console.log(event.data.
|
|
380
|
+
case 'group_participant.joined':
|
|
381
|
+
console.log(event.data.group.uid, event.data.participant.identity);
|
|
285
382
|
break;
|
|
286
383
|
default:
|
|
287
384
|
// A type your code does not know yet: it is not an error, answer 2xx anyway.
|
|
@@ -289,28 +386,29 @@ function handle(event) {
|
|
|
289
386
|
}
|
|
290
387
|
```
|
|
291
388
|
|
|
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`…
|
|
389
|
+
- `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
390
|
- To just get a yes or no, `Funnelchat.webhooks.verify(rawBody, signatureHeader, secret)` returns `true` or `false` and never throws.
|
|
294
391
|
- 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
392
|
- 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
393
|
- `webhookEvents` lists the types the SDK knows, with their status (`live` or `planned`).
|
|
297
394
|
|
|
298
|
-
|
|
395
|
+
#### Subscribe
|
|
299
396
|
|
|
300
397
|
```ts
|
|
301
398
|
const subscription = await funnelchat.webhooks.create({
|
|
302
399
|
url: 'https://your-server.example/hooks/funnelchat',
|
|
303
|
-
events: ['message.received', 'message.status_changed', '
|
|
400
|
+
events: ['message.received', 'message.status_changed', 'device.connected', 'device.disconnected', 'group_participant.joined', 'group_participant.left'],
|
|
304
401
|
description: 'My integration',
|
|
305
402
|
});
|
|
306
403
|
// Store subscription.id and subscription.secret: the secret is shown only once.
|
|
307
404
|
```
|
|
308
405
|
|
|
406
|
+
- Name every event you want: `message.status_changed` too. `events` is typed with the names the API accepts.
|
|
309
407
|
- 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`.
|
|
310
|
-
-
|
|
408
|
+
- 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
409
|
- `rotateSecret(id)` is **not open to API keys** (`403 route_not_available`): rotate the secret from the panel.
|
|
312
410
|
|
|
313
|
-
|
|
411
|
+
### Pagination
|
|
314
412
|
|
|
315
413
|
`list…` methods return a lazy page: nothing is requested until you use it.
|
|
316
414
|
|
|
@@ -330,7 +428,7 @@ while (page.hasNextPage()) {
|
|
|
330
428
|
|
|
331
429
|
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
430
|
|
|
333
|
-
|
|
431
|
+
### Errors
|
|
334
432
|
|
|
335
433
|
Everything the SDK throws extends `FunnelchatError`. When the API answers with an error, it is an `APIError`:
|
|
336
434
|
|
|
@@ -369,6 +467,7 @@ The errors of the API entry and of access control apply to any route (`ACCESS_ER
|
|
|
369
467
|
| `401 unauthenticated` | The key is missing or not valid. |
|
|
370
468
|
| `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
469
|
| `403 route_not_available` | The route is not open to API keys (or it is turned off). |
|
|
470
|
+
| `404` | The resource does not exist or is of another workspace than the key's. |
|
|
372
471
|
| `404 route_not_found` | The route does not exist in the public API. |
|
|
373
472
|
| `429 daily_cap_exceeded` | The daily call cap of the key was reached. It carries `Retry-After`. |
|
|
374
473
|
| `500 edge_misconfigured` | A problem on our side: report it with the `requestId`. |
|
|
@@ -381,11 +480,11 @@ The errors of the API entry and of access control apply to any route (`ACCESS_ER
|
|
|
381
480
|
const { data, requestId, response } = await funnelchat.contacts.retrieve(2001).withResponse();
|
|
382
481
|
```
|
|
383
482
|
|
|
384
|
-
|
|
483
|
+
### Retries
|
|
385
484
|
|
|
386
485
|
- 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
486
|
- 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 **
|
|
487
|
+
- **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
488
|
- 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
489
|
- Per call you can change `maxRetries`, `timeoutMs` and `signal`:
|
|
391
490
|
|
|
@@ -393,7 +492,7 @@ const { data, requestId, response } = await funnelchat.contacts.retrieve(2001).w
|
|
|
393
492
|
await funnelchat.tags.list({}, { maxRetries: 0, timeoutMs: 5000, signal: AbortSignal.timeout(10_000) });
|
|
394
493
|
```
|
|
395
494
|
|
|
396
|
-
|
|
495
|
+
### Any route
|
|
397
496
|
|
|
398
497
|
`request` calls any route. With a route of the spec, the path parameters, the query, the body and the response are typed:
|
|
399
498
|
|
|
@@ -415,27 +514,26 @@ The query is built the way Laravel reads it: arrays as `key[]=a&key[]=b`, object
|
|
|
415
514
|
|
|
416
515
|
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
516
|
|
|
418
|
-
|
|
517
|
+
### Test against the sandbox
|
|
419
518
|
|
|
420
|
-
|
|
519
|
+
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
520
|
|
|
422
521
|
```ts
|
|
423
522
|
const funnelchat = new Funnelchat({ apiKey: '1|fc_live_sandbox', baseUrl: 'http://localhost:4010' });
|
|
424
523
|
```
|
|
425
524
|
|
|
426
|
-
|
|
525
|
+
### Pending of the spec
|
|
427
526
|
|
|
428
|
-
The
|
|
527
|
+
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
528
|
|
|
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.
|
|
529
|
+
- a message to a group and its action (`groups.sendMessage`, `groups.retrieveAction`, `group_action.completed`): the spec was built with the group actions off;
|
|
530
|
+
- 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;
|
|
531
|
+
- WhatsApp forms (except sending one) and `webhooks.rotateSecret`;
|
|
532
|
+
- 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
533
|
|
|
436
|
-
`src/pending/` has what is not OpenAPI (`
|
|
534
|
+
`src/pending/` has what is not OpenAPI (the `workspace_id` of every event) and `src/core/error-codes.ts` the error codes.
|
|
437
535
|
|
|
438
|
-
|
|
536
|
+
### Development
|
|
439
537
|
|
|
440
538
|
```bash
|
|
441
539
|
npm install
|
|
@@ -454,12 +552,12 @@ To release, push a `vX.Y.Z` tag on `main` matching the `version` in `package.jso
|
|
|
454
552
|
Everything generated comes from one script:
|
|
455
553
|
|
|
456
554
|
```bash
|
|
457
|
-
npm run generate -- --spec <path>/funnelchat.json # with a new unified spec of the portal
|
|
458
|
-
npm run generate
|
|
555
|
+
npm run generate -- --spec <path>/funnelchat-completa.json # with a new full unified spec of the portal
|
|
556
|
+
npm run generate # with the one in spec/, for example after editing spec/pending.json
|
|
459
557
|
```
|
|
460
558
|
|
|
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.
|
|
559
|
+
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.
|
|
560
|
+
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
561
|
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
562
|
|
|
465
563
|
```
|