whisp-js 0.1.0 → 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 +540 -96
- package/dist/cjs/client.d.ts +52 -3
- package/dist/cjs/client.js +113 -3
- package/dist/cjs/constants.d.ts +2 -2
- package/dist/cjs/constants.js +2 -1
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/listener.d.ts +2 -2
- package/dist/cjs/listener.js +3 -14
- package/dist/cjs/types.d.ts +69 -2
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/client.d.ts +52 -3
- package/dist/esm/client.js +113 -3
- package/dist/esm/constants.d.ts +2 -2
- package/dist/esm/constants.js +2 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/listener.d.ts +2 -2
- package/dist/esm/listener.js +3 -14
- package/dist/esm/types.d.ts +69 -2
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,13 +8,34 @@ Send WhatsApp messages from your Whisp numbers, read their chats, and react to w
|
|
|
8
8
|
- `on('message', ...)` over a live event stream that resumes where it stopped.
|
|
9
9
|
- `verifyWebhook` checks the `X-Whisp-Signature` header of webhook requests.
|
|
10
10
|
|
|
11
|
+
## Contents
|
|
12
|
+
|
|
13
|
+
- [Install](#install)
|
|
14
|
+
- [Quick start](#quick-start)
|
|
15
|
+
- [The client](#the-client): options, auth, numbers and pools, daily caps
|
|
16
|
+
- [Sending](#sending): text, files, voice notes, replies, mentions, `wait`, idempotency
|
|
17
|
+
- [Message actions](#message-actions): react, edit, delete for everyone
|
|
18
|
+
- [Chats](#chats): mark read, typing
|
|
19
|
+
- [Reading](#reading): numbers, messages, chats, phone checks, downloads
|
|
20
|
+
- [Listening](#listening): `on()`, every event, cursors and reconnects
|
|
21
|
+
- [Webhooks](#webhooks)
|
|
22
|
+
- [Errors](#errors): `WhispError` and every code
|
|
23
|
+
- [Limits and guardrails](#limits-and-guardrails)
|
|
24
|
+
- [Server updates (`WORKER_OUTDATED`)](#server-updates-worker_outdated)
|
|
25
|
+
- [Security](#security)
|
|
26
|
+
- [Everything the package exports](#everything-the-package-exports)
|
|
27
|
+
- [Versions](#versions)
|
|
28
|
+
- [FAQ](#faq)
|
|
29
|
+
|
|
11
30
|
## Install
|
|
12
31
|
|
|
13
32
|
```sh
|
|
14
|
-
npm
|
|
33
|
+
npm i whisp-js
|
|
15
34
|
# or: bun add whisp-js
|
|
16
35
|
```
|
|
17
36
|
|
|
37
|
+
Node 22.12 or later, or Bun 1.2 or later. The package ships ESM (`import { Whisp } from 'whisp-js'`) and CommonJS (`const { Whisp } = require('whisp-js')`) with types for both. Deno and edge runtimes can send and read; `on()` needs a process that stays up.
|
|
38
|
+
|
|
18
39
|
## Quick start
|
|
19
40
|
|
|
20
41
|
Copy the token from your project's **API & SDK** tab and set it on your server as `WHISP_API_TOKEN`. The token can send from every number of the project: keep it on the server, never in a browser bundle or a repository.
|
|
@@ -28,25 +49,72 @@ const support = new Whisp({ phone: '972501234567' })
|
|
|
28
49
|
const message = await support.send('972521112233', 'Your order shipped')
|
|
29
50
|
console.log(message.id, message.status) // msg_..., 'pending'
|
|
30
51
|
|
|
31
|
-
support.on('message', msg => {
|
|
32
|
-
|
|
52
|
+
support.on('message', async msg => {
|
|
53
|
+
await support.startTyping(msg.chatId) // "typing..." until the reply goes out
|
|
54
|
+
const answer = await askYourModel(msg.text)
|
|
55
|
+
await support.send(msg.chatId, answer, { replyTo: msg.id }) // the same chat, a group too
|
|
33
56
|
})
|
|
34
57
|
```
|
|
35
58
|
|
|
36
|
-
Phones are WhatsApp digits in international form: `972501234567`. The SDK also takes `+972 50-123-4567` and turns it into digits.
|
|
59
|
+
Phones are WhatsApp digits in international form: `972501234567`. The SDK also takes `+972 50-123-4567` and turns it into digits. A recipient (`to`) or a chat (`chat`) is named by the other side's phone, the chat's `cht_` id (`msg.chatId`), a group's JID (`120363025246125486@g.us`) or a LID JID (`123456789012345@lid`, a contact WhatsApp knows without a phone).
|
|
60
|
+
|
|
61
|
+
## The client
|
|
62
|
+
|
|
63
|
+
### `new Whisp(options?: WhispOptions)`
|
|
64
|
+
|
|
65
|
+
Nothing is called when you construct it. The token and the base URL are read when a call needs them, so a value set after import still counts. Bad options throw at once (`OPTIONS_INVALID`, `PHONE_INVALID`, or `TOKEN_IN_BROWSER` in a browser).
|
|
66
|
+
|
|
67
|
+
| Option | Type | Default | |
|
|
68
|
+
|---|---|---|---|
|
|
69
|
+
| `token` | `string` | `WHISP_API_TOKEN` | The project token (`wsp_live_...`). |
|
|
70
|
+
| `phone` | `string \| readonly string[]` | the whole project | A phone or a `num_` id, or an array of them (a pool, at most 20). |
|
|
71
|
+
| `strategy` | `'healthiest' \| 'random' \| 'round-robin'` | `'healthiest'` | How a pool picks its sender. Only used with several numbers or none. |
|
|
72
|
+
| `sticky` | `boolean` | `true` | Keep a recipient on the number that last talked to them. |
|
|
73
|
+
| `dailyCap` | `number` | none | 1 to 10,000: send from a number only while it sent fewer in the last 24 hours. See [Daily caps](#daily-caps). |
|
|
74
|
+
| `baseUrl` | `string` | `WHISP_API_URL`, else `https://api.whisp.lue.my` | |
|
|
75
|
+
| `timeoutMs` | `number` | `30000` | Per attempt, 1 to 600,000. A send with `wait` gets at least the wait plus 10 s. |
|
|
76
|
+
| `maxRetries` | `number` | `2` | Attempts after the first, 0 to 10. |
|
|
77
|
+
| `cursor` | `string` | none | Resume the event stream after this cursor (a saved `whisp.cursor`). |
|
|
78
|
+
| `fetch` | `typeof fetch` | the global `fetch` | Any WHATWG fetch, looked up on each call. |
|
|
79
|
+
| `dangerouslyAllowBrowser` | `boolean` | `false` | See [Security](#security). |
|
|
37
80
|
|
|
38
|
-
|
|
81
|
+
`DEFAULT_TIMEOUT_MS` (30,000) and `DEFAULT_MAX_RETRIES` (2) are exported.
|
|
82
|
+
|
|
83
|
+
### Auth
|
|
84
|
+
|
|
85
|
+
One token per project, shown once in the panel and stored hashed. Pass it as `token`, or set `WHISP_API_TOKEN` (`API_TOKEN_ENV`). The SDK sends it as `Authorization: Bearer <token>` to the API's origin and nowhere else. A missing token is `TOKEN_MISSING` on the first call; one that isn't shaped like a project token is `TOKEN_INVALID` before anything is sent. Regenerating the token in the panel revokes the old one at once (`TOKEN_REVOKED`).
|
|
86
|
+
|
|
87
|
+
### Which number sends
|
|
39
88
|
|
|
40
89
|
| `phone` | Sends from |
|
|
41
90
|
|---|---|
|
|
42
91
|
| `'972501234567'` or `'num_7Hq2kD3x'` | That number. The identifier is on the number's page. |
|
|
43
|
-
| `['972501234567', '972501234568']` | One of them, picked on the server for every send
|
|
92
|
+
| `['972501234567', '972501234568']` | One of them, picked on the server for every send by `strategy`. |
|
|
44
93
|
| left out | The project's default number (Settings), else every number of the project as a pool. |
|
|
45
94
|
|
|
46
95
|
`healthiest` ranks the pool's numbers on every send: healthy before watch, then numbers with a sending record before new or idle ones, then the lower health score (in bands of 10, so numbers of about the same health share the traffic), then the most daily cap left, then the fewest messages sent this hour, then at random. Messages still queued on a number count as sent, so a burst of sends spreads over the pool instead of piling up behind one number. Numbers that are not connected, at risk, paused, at their daily cap (queued messages included) or with a full queue are skipped. Each number's `healthScore` (0 to 100, lower is healthier) is on `whisp.numbers()`. A number that hasn't sent 20 messages in the last 24 hours (just linked, or idle for a day) has no record yet: it scores at least 30 and takes the pool's messages only when no healthy number with a record can, so a new number doesn't get the pool's cold traffic.
|
|
47
96
|
|
|
48
97
|
Pools are sticky by default: a recipient keeps getting messages from the number that last talked to them, so the conversation stays in one chat. With `healthiest`, only while that number is healthy and within one score band of the best one; otherwise the healthiest number takes the message and the recipient stays with it from then on. `sticky: false` (a good fit for one-time codes) always takes the healthiest. A contact WhatsApp only knows by a LID (no phone) can't be matched by phone, so in a pool it may land on another number.
|
|
49
98
|
|
|
99
|
+
A send from one number that is reconnecting waits on the server and goes out when it reconnects; a disconnected number answers `NUMBER_NOT_CONNECTED` (in a pool it is skipped instead).
|
|
100
|
+
|
|
101
|
+
### `whisp.number(phoneOrId: string): Whisp`
|
|
102
|
+
|
|
103
|
+
A copy with the same options that sends from, reads and listens on that one number. Throws `PHONE_INVALID` for anything that is neither a phone nor a `num_` id. `chats`, `chat`, `chatMessages`, `check`, `markRead`, `startTyping` and `stopTyping` need one number (a `phone` string, or an array of one): on a pool or a whole-project instance they fail with `NUMBER_REQUIRED` before any request. `chats` and `chatMessages` throw it from the call; the others reject.
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const whisp = new Whisp() // the whole project
|
|
107
|
+
const support = whisp.number('num_7Hq2kD3x')
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### `whisp.verify(): Promise<VerifyResult>`
|
|
111
|
+
|
|
112
|
+
Checks the token (`GET /project`) and that every phone or id you named belongs to the project (`GET /numbers`). Resolves `{ project, token, numbers }`; rejects with `NUMBER_NOT_IN_PROJECT` (`params.number` lists the ones that aren't) or the token's error. Optional: the first send or listen reports the same.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const { project, numbers } = await new Whisp({ phone: ['972501234567', '972501234568'] }).verify()
|
|
116
|
+
```
|
|
117
|
+
|
|
50
118
|
### Daily caps
|
|
51
119
|
|
|
52
120
|
Each number has a daily cap in its settings in the panel (600 messages in any 24 hours unless changed). The SDK can set one too:
|
|
@@ -59,32 +127,53 @@ await otp.send('972521112233', 'Your code is 115720', { dailyCap: 100 }) // this
|
|
|
59
127
|
|
|
60
128
|
`dailyCap` goes with every send; per send it replaces the instance's. A number may send only while it has sent fewer messages than the lower of the two caps in the last 24 hours. A pool skips numbers at their cap; a single number at its cap answers `DAILY_CAP_REACHED` with `params.cap` (the cap that applied), `params.used` and `params.resetAt`. The server counts and enforces it: the SDK never counts by itself, because several instances of your app can share one number. The server checks it when it accepts a send, on counts up to 10 seconds old, so a burst of sends in parallel can pass the cap by a few messages.
|
|
61
129
|
|
|
62
|
-
|
|
130
|
+
## Sending
|
|
131
|
+
|
|
132
|
+
Every send resolves the message as the API created it: `pending` until the number sends it, or its status after `wait`. A message that can't go out ends as a `message.status` event with `status: 'failed'` and an `error.code` (see [Why a message failed](#why-a-message-failed)).
|
|
133
|
+
|
|
134
|
+
### `send(to: string, text: string, options?: SendOptions): Promise<Message>`
|
|
63
135
|
|
|
64
136
|
```ts
|
|
65
|
-
const
|
|
66
|
-
|
|
137
|
+
const message = await whisp.send('972521112233', 'Hello from Whisp', {
|
|
138
|
+
reference: 'order-1042', // your own id (1-128 characters), echoed on the message and its events
|
|
139
|
+
replyTo: 'msg_...', // a msg_ id or a WhatsApp id of a message in the same chat
|
|
140
|
+
wait: true, // wait up to 15 s for the outcome (or a number of ms, 1,000 to 20,000)
|
|
141
|
+
dailyCap: 300, // this send only (see Daily caps)
|
|
142
|
+
idempotencyKey: 'order-1042-shipped', // default: a new UUID per call, kept across its retries
|
|
143
|
+
mentions: ['972521112233'], // groups only (see Mentions)
|
|
144
|
+
})
|
|
67
145
|
```
|
|
68
146
|
|
|
69
|
-
|
|
147
|
+
`SendOptions`:
|
|
70
148
|
|
|
71
|
-
|
|
149
|
+
| Field | Type | |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `replyTo` | `string` | A `msg_` id or a WhatsApp id of a message in the same chat. `REPLY_TARGET_NOT_FOUND` otherwise. |
|
|
152
|
+
| `reference` | `string` | Your own id, 1-128 characters, on the message and its events; find messages by it with `messages()`. |
|
|
153
|
+
| `wait` | `boolean \| number` | Wait for the outcome: `true` is 15 s, or 1,000 to 20,000 ms. The message comes back `sent` or `failed`, or still `pending` when the wait ran out. |
|
|
154
|
+
| `dailyCap` | `number` | This send's cap (1 to 10,000), in place of the instance's. |
|
|
155
|
+
| `idempotencyKey` | `string` | Makes retries safe across your own restarts too: 1 to 255 printable ASCII characters, no spaces. |
|
|
156
|
+
| `mentions` | `string[]` | Phones or `<digits>@lid` JIDs, each written `@<digits>` in the text. Groups only, at most 50. |
|
|
157
|
+
|
|
158
|
+
Errors: `RECIPIENT_INVALID`, `NUMBER_NOT_IN_PROJECT`, `NUMBER_NOT_CONNECTED`, `NO_NUMBER_AVAILABLE`, `NUMBER_PAUSED`, `DAILY_CAP_REACHED`, `SEND_QUEUE_FULL`, `GROUPS_IGNORED`, `NOT_IN_GROUP`, `REPLY_TARGET_NOT_FOUND`, `IDEMPOTENCY_KEY_INVALID`, `IDEMPOTENCY_KEY_REUSED`, the mention codes, `VALIDATION_FAILED`, `RATE_LIMITED`, `OPTIONS_INVALID` (a bad `dailyCap` or `mentions`, before any request), `WORKER_OUTDATED` (a send with `mentions` during a server update).
|
|
159
|
+
|
|
160
|
+
### Idempotency
|
|
161
|
+
|
|
162
|
+
Every send carries an `Idempotency-Key`, the same on each of its retries, so a retry after a timeout never sends twice. Pass your own `idempotencyKey` to stay safe across your own restarts too: the same key and body within 24 hours answer the first message again; the same key with another body is `IDEMPOTENCY_KEY_REUSED`.
|
|
72
163
|
|
|
73
164
|
```ts
|
|
74
|
-
await whisp.send('972521112233', '
|
|
75
|
-
reference: 'order-1042', // your own id, echoed on the message and its events
|
|
76
|
-
replyTo: 'msg_...', // a message in the same chat
|
|
77
|
-
wait: true, // wait up to 15 s for the outcome (or a number of ms, up to 20,000)
|
|
78
|
-
dailyCap: 300, // this send only: a number that sent 300 in 24 h is skipped (see Daily caps)
|
|
79
|
-
idempotencyKey: 'order-1042-shipped', // default: a new UUID per call
|
|
80
|
-
})
|
|
165
|
+
await whisp.send('972521112233', 'Your order shipped', { idempotencyKey: `order-${order.id}-shipped` })
|
|
81
166
|
```
|
|
82
167
|
|
|
83
|
-
|
|
168
|
+
### Files: `sendImage`, `sendVideo`, `sendDocument`
|
|
84
169
|
|
|
85
|
-
|
|
170
|
+
```ts
|
|
171
|
+
sendImage(to: string, media: MediaSource, options?: SendFileOptions): Promise<Message>
|
|
172
|
+
sendVideo(to: string, media: MediaSource, options?: SendFileOptions): Promise<Message>
|
|
173
|
+
sendDocument(to: string, media: MediaSource, options?: SendFileOptions): Promise<Message>
|
|
174
|
+
```
|
|
86
175
|
|
|
87
|
-
|
|
176
|
+
`SendFileOptions` is `SendOptions` plus `caption?: string` (at most 1,024 characters).
|
|
88
177
|
|
|
89
178
|
```ts
|
|
90
179
|
// A public https URL: the server downloads it.
|
|
@@ -92,48 +181,236 @@ await whisp.sendImage('972521112233', { url: 'https://example.com/receipt.png' }
|
|
|
92
181
|
|
|
93
182
|
// Bytes you hold: the SDK uploads them first.
|
|
94
183
|
await whisp.sendDocument('972521112233', { file: pdfBytes, fileName: 'invoice.pdf' })
|
|
95
|
-
await whisp.sendVoice('972521112233', { file: Bun.file('note.ogg') })
|
|
96
184
|
await whisp.sendVideo('972521112233', { file: await fs.openAsBlob('clip.mp4'), mime: 'video/mp4' })
|
|
97
185
|
```
|
|
98
186
|
|
|
99
|
-
`
|
|
187
|
+
`MediaSource` is one of:
|
|
188
|
+
|
|
189
|
+
- `MediaUrl`: `{ url: string; fileName?: string; mime?: string }`. https only; the server fetches it, refusing private addresses and redirects (`MEDIA_URL_NOT_ALLOWED`). A URL that can't be fetched fails the message later with `MEDIA_FETCH_FAILED`, one over its kind's limit with `MEDIA_TOO_LARGE`.
|
|
190
|
+
- `MediaFile`: `{ file: Blob | Uint8Array | ArrayBuffer | ReadableStream<Uint8Array>; fileName?: string; mime?: string; size?: number }`. A `Blob` is a `File`, `Bun.file()` or Node's `fs.openAsBlob()`. The upload signs the file's exact length, so a stream is read into memory first; pass a `Blob` for big files. The type comes from `mime`, else the Blob's type, else the file name's extension.
|
|
191
|
+
|
|
192
|
+
Limits: images 16 MB (JPEG, PNG, WebP, GIF), videos 64 MB (MP4), voice notes 16 MB (OGG, MP3, M4A, AAC, WAV, WebM), documents 100 MB (any type). A file the SDK can't upload as given (no bytes, no type, too big) is `MEDIA_INVALID` before anything is sent; a failed PUT to storage is `UPLOAD_FAILED`. Anything else than `{ url }` or `{ file }` is `MEDIA_INVALID`.
|
|
193
|
+
|
|
194
|
+
### Voice notes: `sendVoice(to: string, media: MediaSource, options?: SendVoiceOptions): Promise<Message>`
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
await whisp.sendVoice('972521112233', { file: Bun.file('note.ogg') })
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`SendVoiceOptions` is `SendOptions` without `mentions` (a voice note has no text to carry them). The server converts it to OGG/Opus, and WhatsApp shows "recording audio..." for its length before it goes out (see [Limits and guardrails](#limits-and-guardrails)).
|
|
201
|
+
|
|
202
|
+
### Any body: `sendMessage(body: SendMessageBody, options?: Pick<SendOptions, 'idempotencyKey'>): Promise<Message>`
|
|
203
|
+
|
|
204
|
+
A raw `POST /messages` body for anything the helpers don't cover. The instance's `from`, `strategy`, `sticky` and `dailyCap` fill in what the body leaves out.
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
await whisp.sendMessage({ to: '972521112233', type: 'document', media: { uploadId }, caption: 'Signed' })
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Mentions
|
|
211
|
+
|
|
212
|
+
In a group, a text or a caption can mention people. Name each one in `mentions`, by phone or by `<digits>@lid` JID, and write `@` and the same digits in the text; WhatsApp shows the person's name there and notifies them:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
const team = whisp.number('972501234567')
|
|
216
|
+
await team.send('120363025246125486@g.us', 'Thanks @972521112233, and welcome @123456789012345!', {
|
|
217
|
+
mentions: ['+972 52-111-2233', '123456789012345@lid'],
|
|
218
|
+
})
|
|
219
|
+
await team.sendImage('120363025246125486@g.us', { url: 'https://example.com/plan.png' }, {
|
|
220
|
+
caption: '@972521112233 the new plan',
|
|
221
|
+
mentions: ['972521112233'],
|
|
222
|
+
})
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
- Groups only: a mention in a direct chat is `MENTIONS_NEED_GROUP`. Each mention's `@<digits>` must be in the text (`MENTION_NOT_IN_TEXT`, with `params.mention`). At most 50; one person in two spellings counts once. A ref that is neither a phone nor a LID JID is `VALIDATION_FAILED` with `params.reason: 'mentions'`.
|
|
226
|
+
- Some groups hide their members' phone numbers (LID-addressed groups). There the server sends each phone mention as the person's LID, so the phone is never shown to the group. When nobody can match a phone to its LID (someone who never wrote to the number), the message fails with `MENTION_UNRESOLVED` (a `message.status` event, `status: 'failed'`) and nothing is sent. Mention that person by `<digits>@lid` instead: their LID is on a message they sent (`sender.lid`) and in the `group.participants` event.
|
|
227
|
+
- Received and sent messages carry `mentions`: the people mentioned, each with `phone`, `lid` and the `name` your project knows them by. `@` and their phone or LID digits mark each one in `text`.
|
|
228
|
+
|
|
229
|
+
### Waiting for the outcome
|
|
230
|
+
|
|
231
|
+
`wait` holds the request on the server until the message is `sent` or `failed`, up to the wait. Without it, follow the message with events (`message.sent`, `message.status`) or read it again:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
const sent = await whisp.send('972521112233', 'Your code is 482913', { wait: 10_000 })
|
|
235
|
+
if (sent.status === 'failed') console.log(sent.error?.code)
|
|
236
|
+
else if (sent.status === 'pending') console.log('still queued: follow it with events')
|
|
237
|
+
|
|
238
|
+
const later = await whisp.message(sent.id)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`wait` doesn't make a send any slower to go out: the guardrails' delay and typing apply either way (see [Limits and guardrails](#limits-and-guardrails)), so with a wait under about 5 s a send that queued behind others can come back `pending`.
|
|
100
242
|
|
|
101
|
-
|
|
243
|
+
## Message actions
|
|
244
|
+
|
|
245
|
+
None of these carries an `Idempotency-Key`: each is idempotent by state, so the SDK retries them like reads. None counts toward the daily cap, the guardrails or the number's health.
|
|
246
|
+
|
|
247
|
+
### `react(message: Message | string, emoji: string): Promise<void>`
|
|
248
|
+
|
|
249
|
+
Sets this number's reaction to a message (one emoji: a ZWJ sequence, a flag or a skin tone included), replacing its earlier one. Resolves once the server queued it for the number's connection; a `message.reaction` event with `fromMe: true` follows.
|
|
250
|
+
|
|
251
|
+
### `unreact(message: Message | string): Promise<void>`
|
|
252
|
+
|
|
253
|
+
Removes this number's reaction.
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
await whisp.react(message, '👍') // a Message or its msg_ id
|
|
257
|
+
await whisp.unreact(message.id)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Errors: `MESSAGE_NOT_FOUND`, `MESSAGE_DELETED`, `MESSAGE_NOT_SENT` (this number's own message that failed, or hasn't gone out yet: held by a pause, the daily cap or the queue), `NOT_IN_GROUP`, `GROUPS_IGNORED`, `NUMBER_NOT_CONNECTED` (neither connected nor reconnecting), `VALIDATION_FAILED` (`params.reason: 'emoji'` for anything but one emoji), `WORKER_OUTDATED`, `RATE_LIMITED`; `OPTIONS_INVALID` locally for an empty emoji (use `unreact`) or a bad message id. A reaction to a number that is reconnecting goes out when it is back, within 10 minutes. A paused number may react.
|
|
261
|
+
|
|
262
|
+
### `edit(message: Message | string, text: string, options?: EditOptions): Promise<Message>`
|
|
263
|
+
|
|
264
|
+
Edits the text, or the caption of a picture, video or document, of a message this number sent, within WhatsApp's 15 minutes. Resolves the edited message; the same text without `mentions` resolves it unchanged and sends nothing. A `message.edited` event follows.
|
|
265
|
+
|
|
266
|
+
`EditOptions`: `{ mentions?: string[] }` replaces the message's mentions; left out, the mentions still named in the new text are kept.
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
const fixed = await whisp.edit(message, 'Your code is 482914')
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Errors: `MESSAGE_NOT_OURS`, `MESSAGE_NOT_SENT` (retry a failed message first), `MESSAGE_DELETED`, `MESSAGE_NOT_EDITABLE` (`params.reason`: `type`, `no_caption`, `no_raw`), `EDIT_WINDOW_PASSED` (`params.sentAt`, `params.windowMinutes`), `NUMBER_PAUSED` (an edit puts new text in front of everyone in the chat), `NUMBER_NOT_CONNECTED`, `NOT_IN_GROUP`, `GROUPS_IGNORED`, the mention codes, `WA_REJECTED` (WhatsApp's own text in `params.detail`), `VALIDATION_FAILED` (`params.reason: 'caption'` past 1,024 characters), `WORKER_UNAVAILABLE`, `WORKER_OUTDATED`, `RATE_LIMITED`; `OPTIONS_INVALID` locally (empty text, a bad message id, `mentions` that isn't a non-empty list).
|
|
273
|
+
|
|
274
|
+
### `revoke(message: Message | string): Promise<Message>`
|
|
275
|
+
|
|
276
|
+
Deletes a message this number sent for everyone, within 2 days. Resolves the message with `deletedAt` set, also when it was deleted already. A `message.deleted` event follows.
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
const gone = await whisp.revoke(message)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Errors: `MESSAGE_NOT_OURS`, `MESSAGE_NOT_SENT`, `REVOKE_WINDOW_PASSED` (`params.sentAt`, `params.windowHours`), `NUMBER_NOT_CONNECTED`, `NOT_IN_GROUP`, `GROUPS_IGNORED`, `WA_REJECTED`, `WORKER_UNAVAILABLE`, `WORKER_OUTDATED`, `RATE_LIMITED`; `OPTIONS_INVALID` locally (a bad message id). A paused number may delete.
|
|
283
|
+
|
|
284
|
+
`WORKER_UNAVAILABLE` on an edit or a delete means the server didn't hear back from WhatsApp in time: it may still go through, and if it does the message is updated and the event sent as usual, so check the message (or wait for the event) before you call again. A number takes at most 30 edits and deletes a minute, from the API and the panel together.
|
|
285
|
+
|
|
286
|
+
## Chats
|
|
287
|
+
|
|
288
|
+
### `markRead(chat: string): Promise<MarkReadResult>`
|
|
289
|
+
|
|
290
|
+
Marks a chat of this instance's number read (a phone, a `cht_` id or a group JID): zeroes its unread count and sends read receipts (the sender's blue ticks) when the number's setting sends them, as opening the chat in the panel does. Needs one number.
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
const { chat, readReceipts } = await whisp.number('972501234567').markRead('972521112233')
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
- `MarkReadResult` is `{ chat: Chat; readReceipts: 'queued' | 'off' | 'none' }`.
|
|
297
|
+
- Unread is one count per chat, shared with the panel: this clears it for your team's members too.
|
|
298
|
+
- `readReceipts` is `'queued'` (handed to the server; not proof WhatsApp got them, since a number that is not connected drops them), `'off'` (the number's or the project's setting) or `'none'` (nothing was unread).
|
|
299
|
+
- Works on a paused number and while it reconnects. Errors: `NUMBER_REQUIRED` (locally), `NUMBER_NOT_IN_PROJECT`, `CHAT_NOT_FOUND`, `RECIPIENT_INVALID`, `RATE_LIMITED`.
|
|
300
|
+
|
|
301
|
+
### `startTyping(chat: string, options?: TypingOptions): Promise<TypingResult>`
|
|
302
|
+
|
|
303
|
+
Shows "typing..." (or "recording audio..." with `state: 'recording'`) in a chat of this instance's number while your app works on a reply. The server keeps it up, sending it again every 10 seconds, until the first of:
|
|
304
|
+
|
|
305
|
+
- a message of this number goes out in that chat, whoever sends it (your send, a teammate in the inbox, Whisp's own reply to a call, or a message typed on the phone). **You don't need to stop before sending.** Nothing more is sent: the message ends the recipient's "typing..." by itself. A phone message from before your start (one WhatsApp delivers late, after a reconnect) doesn't end it.
|
|
306
|
+
- `stopTyping(chat)`, 2 minutes after the latest start (call it again to keep going), the number being paused or starting to ignore groups (in a group chat), a message of this number failing in that chat (the reply won't come), or the number typing in a 26th chat (at most 25 per number are kept; the oldest ends). Each of these sends WhatsApp's "paused" at once.
|
|
307
|
+
- the number's connection closing, or the server restarting. Nothing is sent: the typing fades on the recipient's phone within about 25 seconds.
|
|
308
|
+
|
|
309
|
+
`TypingOptions`: `{ state?: 'composing' | 'recording' }`, `composing` by default. `TypingResult` is `{ state: 'composing' | 'recording' | 'paused'; keptUntil: string | null }`: `keptUntil` is when it stops at the latest (2 minutes from now), or null while the server's worker is an older build that shows typing once, for about 25 seconds, with no keep-alive.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
const bot = whisp.number('972501234567')
|
|
313
|
+
bot.on('message', async msg => {
|
|
314
|
+
await bot.startTyping(msg.chatId)
|
|
315
|
+
const answer = await askYourModel(msg.text) // takes 20 s: "typing..." stays up
|
|
316
|
+
await bot.send(msg.chatId, answer) // ends the typing; no stopTyping needed
|
|
317
|
+
})
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
- Calling it again is cheap: a repeated start within 5 seconds sends nothing to WhatsApp and only moves the 2-minute limit; switching between `composing` and `recording` goes out at once.
|
|
321
|
+
- A number that is reconnecting starts typing once it is back (within the 2 minutes).
|
|
322
|
+
- Typing is not a send: no daily cap, no guardrail delay, no effect on health. Whisp's own typing before an API send (see [Limits and guardrails](#limits-and-guardrails)) is skipped while yours is on, and its wait counts the time yours has already shown, so a bot that typed for 20 seconds isn't held another 4.
|
|
323
|
+
- It shows a chatstate only: the number is not shown online by it.
|
|
324
|
+
- Errors: `NUMBER_REQUIRED` and `OPTIONS_INVALID` (a state other than the two) locally; `NUMBER_PAUSED` (its message would be held, so the recipient would watch "typing..." for nothing), `NOT_IN_GROUP`, `GROUPS_IGNORED`, `NUMBER_NOT_CONNECTED` (neither connected nor reconnecting), `CHAT_NOT_FOUND` (typing needs a chat that exists: send first to a new phone), `NUMBER_NOT_IN_PROJECT`, `RECIPIENT_INVALID`, `RATE_LIMITED`.
|
|
325
|
+
|
|
326
|
+
### `stopTyping(chat: string): Promise<void>`
|
|
327
|
+
|
|
328
|
+
Stops this number's typing in the chat at once (WhatsApp's "paused"). Safe to call when nothing is typing, and never refused for the number's state: a paused or disconnected number may always stop. Errors: `NUMBER_REQUIRED` (locally), `NUMBER_NOT_IN_PROJECT`, `CHAT_NOT_FOUND`, `RECIPIENT_INVALID`, `RATE_LIMITED`.
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
await bot.startTyping(chat)
|
|
332
|
+
try {
|
|
333
|
+
await bot.send(chat, await askYourModel(question))
|
|
334
|
+
} catch (error) {
|
|
335
|
+
await bot.stopTyping(chat) // the reply won't come: don't leave "typing..." up
|
|
336
|
+
throw error
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
`startTyping` and `stopTyping` share their own limit of 240 calls a minute per token.
|
|
102
341
|
|
|
103
342
|
## Reading
|
|
104
343
|
|
|
344
|
+
### `numbers(): Promise<WhatsAppNumber[]>`
|
|
345
|
+
|
|
346
|
+
Every number of the project, any status, with `status`, `risk`, `healthScore`, `paused`, `capReached` and `last24h`.
|
|
347
|
+
|
|
348
|
+
### `message(messageId: string): Promise<Message>`
|
|
349
|
+
|
|
350
|
+
One message of the project's numbers; another project's id is `MESSAGE_NOT_FOUND`, as a missing one is.
|
|
351
|
+
|
|
352
|
+
### `messages(query: MessagesQuery): Promise<Message[]>`
|
|
353
|
+
|
|
354
|
+
What earlier sends with this `reference` made, newest first, at most 50. `MessagesQuery` is `{ reference: string }`.
|
|
355
|
+
|
|
105
356
|
```ts
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
357
|
+
const sent = await whisp.messages({ reference: 'order-1042' })
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
### `check(phone: string): Promise<PhoneCheck>`
|
|
109
361
|
|
|
362
|
+
Whether a phone is on WhatsApp, asked through the instance's one number. `PhoneCheck` is `{ phone: string; exists: boolean; jid: string | null }`. Answers are cached for 10 minutes; uncached lookups are limited to 30 a minute per token and 30 a minute per number (`RATE_LIMITED` with `retryAfter`). The number must be connected (`NUMBER_NOT_CONNECTED`). You don't need it before sending: every first send to a phone is checked by the server, and fails with `NOT_ON_WHATSAPP` when it isn't.
|
|
363
|
+
|
|
364
|
+
### `chats(query?: ChatListQuery): PageResult<Chat>`
|
|
365
|
+
|
|
366
|
+
The number's chats, newest activity first. `ChatListQuery` is `{ kind?: 'direct' | 'group'; unread?: boolean; done?: boolean; cursor?: string; limit?: number }` (`limit` 1 to 100, 50 by default).
|
|
367
|
+
|
|
368
|
+
### `chat(chat: string): Promise<Chat>`
|
|
369
|
+
|
|
370
|
+
One chat: a phone, a `cht_` id or a group JID. A merged-away `cht_` id resolves to the chat it joined. `CHAT_NOT_FOUND` otherwise.
|
|
371
|
+
|
|
372
|
+
### `chatMessages(chat: string, query?: ChatMessagesQuery): PageResult<Message>`
|
|
373
|
+
|
|
374
|
+
A chat's messages, newest first, history included. `ChatMessagesQuery` is `{ before?: string; after?: string; limit?: number }`. Each next page is older (its cursor goes in `before`); after an `after` request, each next page is newer.
|
|
375
|
+
|
|
376
|
+
`PageResult<T>` is `Promise<Page<T>> & AsyncIterable<T>`, with `Page<T>` = `{ items: T[]; nextCursor: string | null }`: await it for one page, or `for await` it for every item across pages.
|
|
377
|
+
|
|
378
|
+
```ts
|
|
110
379
|
const support = whisp.number('972501234567')
|
|
111
380
|
const { items, nextCursor } = await support.chats({ unread: true, limit: 20 })
|
|
112
381
|
for await (const msg of support.chatMessages('972521112233')) {
|
|
113
382
|
// newest first, page after page
|
|
114
383
|
}
|
|
115
|
-
const chat = await support.chat('972521112233') // a phone, a cht_ id or a group JID
|
|
116
|
-
const { exists } = await support.check('972521112233') // is this phone on WhatsApp?
|
|
117
384
|
```
|
|
118
385
|
|
|
119
|
-
`chats`, `chat`, `chatMessages` and `check` read one number: on a pool or a whole-project instance they throw `NUMBER_REQUIRED
|
|
386
|
+
`chats`, `chat`, `chatMessages` and `check` read one number: on a pool or a whole-project instance they throw `NUMBER_REQUIRED`. A cursor that isn't one of ours is `VALIDATION_FAILED` with `params.reason: 'cursor'`.
|
|
387
|
+
|
|
388
|
+
### `downloadMedia(message: Message | string, options?: DownloadOptions): Promise<Response>`
|
|
120
389
|
|
|
121
|
-
|
|
390
|
+
A message's file as a fetch `Response`. `DownloadOptions` is `{ variant?: 'original' | 'thumb' }`.
|
|
122
391
|
|
|
123
392
|
```ts
|
|
124
|
-
const response = await whisp.downloadMedia(message)
|
|
393
|
+
const response = await whisp.downloadMedia(message)
|
|
125
394
|
await Bun.write('photo.jpg', response)
|
|
126
395
|
```
|
|
127
396
|
|
|
128
|
-
The SDK asks the API for a 5-minute link with your token, then fetches the link without it. It never follows the API's redirect with the token attached. A file the server is still preparing is waited for, up to 60 s. With cURL, use `-L` without `--location-trusted`, so the `Authorization` header isn't sent on to storage.
|
|
397
|
+
The SDK asks the API for a 5-minute link with your token, then fetches the link without it. It never follows the API's redirect with the token attached. A file the server is still preparing (`MEDIA_NOT_READY`) is waited for, up to 60 s. Errors: `MESSAGE_NOT_FOUND`, `MEDIA_NOT_READY` (after 60 s), `MEDIA_VARIANT_NOT_FOUND` (no thumbnail for this file), `MEDIA_DOWNLOAD_FAILED` (the link answered an error), `OPTIONS_INVALID`. With cURL, use `-L` without `--location-trusted`, so the `Authorization` header isn't sent on to storage.
|
|
129
398
|
|
|
130
399
|
## Listening
|
|
131
400
|
|
|
401
|
+
```ts
|
|
402
|
+
on(event, handler): () => void
|
|
403
|
+
off(event, handler): void
|
|
404
|
+
close(): void
|
|
405
|
+
get cursor(): string | null
|
|
406
|
+
```
|
|
407
|
+
|
|
132
408
|
```ts
|
|
133
409
|
const whisp = new Whisp({ phone: '972501234567', cursor: await loadCursor() })
|
|
134
410
|
|
|
135
411
|
whisp.on('message', async msg => {
|
|
136
|
-
|
|
412
|
+
// chatId is the reply target, a group too; msg.from is the sender and null for a contact without a phone
|
|
413
|
+
await handleReply(msg.chatId, msg.text)
|
|
137
414
|
await saveCursor(whisp.cursor) // resume after this event next time
|
|
138
415
|
})
|
|
139
416
|
whisp.on('message.status', e => console.log(e.messageId, e.previousStatus, '->', e.status))
|
|
@@ -142,41 +419,72 @@ whisp.on('gap', gap => reconcile(gap.reason)) // events may have been missed: ca
|
|
|
142
419
|
whisp.on('error', (error, event) => console.error(error, event?.event.id))
|
|
143
420
|
```
|
|
144
421
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
| `message.received`, `message.sent`, `message.status`, `message.reaction`, `message.deleted` | Messages: in, out (from the API, the inbox or the phone), delivered, read, played or failed, reactions, deletions. |
|
|
149
|
-
| `chat.updated`, `presence.updated` | Unread, done, pinned, assignee, labels, title; typing and online. |
|
|
150
|
-
| `group.updated`, `group.participants` | Group subject, description and settings; members added, removed, promoted, demoted. |
|
|
151
|
-
| `number.status`, `number.risk`, `call.received` | The number connected or disconnected; its health; an incoming call. |
|
|
152
|
-
| `*` | Every event above. |
|
|
153
|
-
| `open`, `close`, `gap`, `error` | The stream itself: connected, disconnected, a resume that couldn't be honoured, a failure. |
|
|
422
|
+
`on` returns a function that removes the handler. `on('x', fn)` with something other than a function throws `OPTIONS_INVALID`.
|
|
423
|
+
|
|
424
|
+
A handler gets the event's `data` with the envelope folded in (`ListenerEvent<T>`): the data's fields, plus `number` (the Whisp number's phone), `numberId`, and `event` (`EventMeta`: `id`, `type`, `createdAt`, `cursor`).
|
|
154
425
|
|
|
155
|
-
|
|
426
|
+
### Events
|
|
427
|
+
|
|
428
|
+
| Name | When | The handler's data (besides `number`, `numberId`, `event`) |
|
|
429
|
+
|---|---|---|
|
|
430
|
+
| `message` | `message.received` | The `Message`. |
|
|
431
|
+
| `message.received` | A new inbound message. | `Message`: `id`, `waId`, `chatId`, `chat`, `fromMe`, `from`, `sender`, `to`, `type`, `text`, `media`, `quoted`, `content`, `status`, `error`, `sentBy`, `reference`, `at`, `editedAt`, `deletedAt`, `mentions`. |
|
|
432
|
+
| `message.sent` | A message of the number went out: from the API, the inbox, Whisp's call reply or the phone (`sentBy`). | `Message`. |
|
|
433
|
+
| `message.status` | Delivered, read, played, or failed. | `messageId`, `waId`, `chatId`, `to`, `status` (`delivered`, `read`, `played`, `failed`), `previousStatus`, `error` (`{ code, message }` or null), `reference`, `at`. |
|
|
434
|
+
| `message.reaction` | A reaction set or removed, theirs or ours. | `messageId`, `waId`, `chatId`, `emoji`, `removed`, `fromMe`, `actor` (`Actor` or null), `reference`, `at`. |
|
|
435
|
+
| `message.deleted` | Deleted for everyone, by them or by us. | `messageId`, `waId`, `chatId`, `fromMe`, `deletedBy`, `reference`, `at`. |
|
|
436
|
+
| `message.edited` | A text or caption edited, theirs or ours. | `messageId`, `waId`, `chatId`, `fromMe`, `text` (the new one), `mentions`, `reference`, `at`. |
|
|
437
|
+
| `chat.updated` | Unread, done, pinned, assignee, labels, title or disappearing messages changed. | `chat` (`Chat`), `changes` (which of `unread`, `done`, `pinned`, `assignee`, `labels`, `title`, `ephemeral`). |
|
|
438
|
+
| `presence.updated` | Someone is typing, recording, paused, online or offline (WhatsApp sends these for chats the number watches: one a member has open in the panel). | `chatId`, `chat` (`{ kind, jid, phone }`), `state` (`available`, `unavailable`, `composing`, `recording`, `paused`), `participant` (in groups), `at`. |
|
|
439
|
+
| `group.updated` | A group's subject, description or settings changed. | `chatId`, `jid`, `changes` (`subject`, `description`, `announce`, `restrict`, `joinApprovalMode`, `memberAddMode`, `ephemeralDuration`, each only when it changed), `by`, `at`. |
|
|
440
|
+
| `group.participants` | Members added, removed, promoted, demoted or changed. | `chatId`, `jid`, `action` (`add`, `remove`, `promote`, `demote`, `modify`), `participants` (`Actor[]`), `by`, `at`. |
|
|
441
|
+
| `number.status` | The number connected, disconnected, or started reconnecting. | `status`, `previousStatus`, `reason` (a `DisconnectReason` or null), `detail` (`{ code, until }` or null), `at`. |
|
|
442
|
+
| `number.risk` | The number's health level changed. | `risk`, `previousRisk`, `reason` (the first reason's sentence), `reasons` (at most two `{ signal, level, value, text }`), `paused` (sending is paused after this change: route sends elsewhere), `at`. |
|
|
443
|
+
| `call.received` | An incoming call. | `callId`, `from` (`Actor`), `chatId`, `video`, `group`, `offline` (delivered after a reconnect, already over), `rejected` (the number's "Reject calls" setting), `at`. |
|
|
444
|
+
| `*` | Every event above. | `AnyListenerEvent`: check `e.event.type`. |
|
|
445
|
+
| `open` | A connection reached `ready`. | `StreamReady`: `{ resumed: boolean; reason?: 'cursor_unknown' \| 'cursor_expired' }`. |
|
|
446
|
+
| `close` | A connection ended. | `StreamClose`: `{ reconnect: boolean; error?: Error }`. `reconnect` is false once the listener stopped. |
|
|
447
|
+
| `gap` | A resume was asked and couldn't be honoured. | `StreamGap`: `{ reason: 'cursor_unknown' \| 'cursor_expired'; cursor: string }`. |
|
|
448
|
+
| `error` | A handler threw, the server sent `error`, or the listener stopped for good. | `(error: Error, event?: AnyListenerEvent)`. |
|
|
449
|
+
|
|
450
|
+
An `Actor` is `{ phone: string | null; lid: string | null; name: string | null }` (`phone` is null for someone WhatsApp only knows by a LID). Every type is exported (`Message`, `Chat`, `MessageStatusData`, `NumberRiskData`, ...); `EventData[T]` is the data of type `T`.
|
|
451
|
+
|
|
452
|
+
### How the stream behaves
|
|
156
453
|
|
|
157
454
|
- **One stream per instance.** The first event handler opens it; removing the last one, or `close()`, ends it. It covers the instance's numbers (all of the project's for a whole-project instance, numbers linked later included) and every type: adding a handler never reconnects.
|
|
158
|
-
- **Order.** Handlers run one at a time, in the order events happened. A handler that throws emits `error` with the event, and the stream goes on. While a handler runs, the stream waits; a listener that falls far behind is caught up from storage on its next connection, so nothing is dropped.
|
|
159
|
-
- **Resuming.** `whisp.cursor` is the position after the last event whose handlers finished (before the first event, where the stream started, as soon as it opens). The server's pings move it on while your numbers are quiet, so a saved cursor stays fresh even when nothing happens for days. Save it, and pass it as `cursor` after a restart: you get every event after it, in order, for up to 7 days. Older than that (or a cursor from another server), `gap` fires once and the stream continues from now. The cursor is not the event id: use `event.id` to drop an event you already handled, since one handled right before a crash, before its cursor was saved, comes again.
|
|
160
|
-
- **Reconnects.** After a dropped connection the SDK reconnects after 1, 2, 4, 8 and 15 seconds, then every 15 seconds, always resuming from the cursor, also before the first event: nothing that happens while it reconnects is lost. A connection silent for 45 seconds is replaced (the server pings every 20). It stops for good, with `error`, when the token is invalid or regenerated (`TOKEN_INVALID`, `TOKEN_REVOKED`)
|
|
455
|
+
- **Order.** Handlers run one at a time, in the order events happened. A handler that throws emits `error` with the event, and the stream goes on. While a handler runs, the stream waits; a listener that falls far behind (1,000 events) is caught up from storage on its next connection, so nothing is dropped. With no `error` handler, errors go to `console.error`.
|
|
456
|
+
- **Resuming.** `whisp.cursor` is the position after the last event whose handlers finished (before the first event, where the stream started, as soon as it opens; null until then unless you passed `cursor`). The server's pings move it on while your numbers are quiet, so a saved cursor stays fresh even when nothing happens for days. Save it, and pass it as `cursor` after a restart: you get every event after it, in order, for up to 7 days (typing and online for an hour). Older than that (or a cursor from another server), `gap` fires once and the stream continues from now. The cursor is not the event id: use `event.id` to drop an event you already handled, since one handled right before a crash, before its cursor was saved, comes again.
|
|
457
|
+
- **Reconnects.** After a dropped connection the SDK reconnects after 1, 2, 4, 8 and 15 seconds, then every 15 seconds, always resuming from the cursor, also before the first event: nothing that happens while it reconnects is lost. A connection silent for 45 seconds is replaced (the server pings every 20). A 429 (`TOO_MANY_STREAMS`: at most 10 open streams per token) waits 30 seconds. It stops for good, with `error` and then `close` with `reconnect: false`, when the token is invalid or regenerated (`TOKEN_INVALID`, `TOKEN_REVOKED`), a number isn't the project's (`NUMBER_NOT_IN_PROJECT`), or the server answers another 4xx a retry can't change (403, 400), and when `token` and `WHISP_API_TOKEN` are both missing (`TOKEN_MISSING`). A server restart ends the stream with a `SHUTDOWN` frame: the SDK reconnects and resumes without emitting `error`. Any other `error` frame is emitted (its `code`, or `STREAM_ERROR`) and the stream reconnects.
|
|
161
458
|
- **Long-lived processes.** The stream suits a server or a worker that stays up. In serverless functions, use webhooks instead.
|
|
162
459
|
|
|
163
460
|
## Webhooks
|
|
164
461
|
|
|
165
|
-
|
|
462
|
+
### `verifyWebhook(input: VerifyWebhookInput): Promise<WebhookEvent>`
|
|
463
|
+
|
|
464
|
+
Also `whisp.verifyWebhook(input)`, the same function. Each request to your endpoint carries the event as JSON and an `x-whisp-signature` header: `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`, keyed with the endpoint's signing secret (`wsp_whs_...`, shown once when you create or rotate it). `verifyWebhook` checks it against the **raw** body, before any JSON parsing, and returns the event typed by `type`.
|
|
465
|
+
|
|
466
|
+
| `VerifyWebhookInput` | Type | |
|
|
467
|
+
|---|---|---|
|
|
468
|
+
| `body` | `string \| Uint8Array \| ArrayBuffer` | The raw body: `await request.text()`, or the Buffer of `express.raw()`. |
|
|
469
|
+
| `signature` | `string \| null \| undefined` | The `x-whisp-signature` header. |
|
|
470
|
+
| `secret` | `string \| readonly string[] \| null \| undefined` | The signing secret; several are tried in turn. |
|
|
471
|
+
| `toleranceSeconds` | `number` | How far the signature's time may be from now. Default 300. |
|
|
472
|
+
| `now` | `number` | "Now" in Unix ms, for tests. |
|
|
166
473
|
|
|
167
474
|
```ts
|
|
168
475
|
import { verifyWebhook, WhispSignatureError } from 'whisp-js'
|
|
169
476
|
|
|
170
477
|
const event = await verifyWebhook({
|
|
171
|
-
body: rawBody,
|
|
478
|
+
body: rawBody,
|
|
172
479
|
signature: request.headers.get('x-whisp-signature'),
|
|
173
480
|
secret: process.env.WHISP_WEBHOOK_SECRET,
|
|
174
|
-
toleranceSeconds: 300, // the default
|
|
175
481
|
})
|
|
176
482
|
if (event.type === 'message.received') console.log(event.data.from, event.data.text)
|
|
177
483
|
```
|
|
178
484
|
|
|
179
|
-
It throws `WhispSignatureError` with `reason` `'missing'`, `'malformed'`, `'timestamp'` (
|
|
485
|
+
It throws `WhispSignatureError` (code `SIGNATURE_INVALID`) with `reason` `'missing'`, `'malformed'`, `'timestamp'` (outside the tolerance: an old or replayed request, or a wrong clock) or `'mismatch'`: answer 400 and do nothing. A missing or mistyped secret throws `WhispError` (`SECRET_MISSING`, `SECRET_INVALID`) instead, since that is your setup, not the request; a runtime without Web Crypto throws `CRYPTO_UNAVAILABLE`.
|
|
486
|
+
|
|
487
|
+
A webhook body is the event envelope: `{ id, type, createdAt, projectId, number: { id, phone }, data }`, with `data` as in the [events table](#events) (not folded: `event.data.text`, `event.number.phone`).
|
|
180
488
|
|
|
181
489
|
**Express**: the raw body, not `express.json()`:
|
|
182
490
|
|
|
@@ -224,10 +532,10 @@ Bun.serve({
|
|
|
224
532
|
})
|
|
225
533
|
```
|
|
226
534
|
|
|
227
|
-
- Answer 2xx quickly. No answer within 10 seconds, a network error, 408, 429 or 5xx is retried up to 8 times over about a day; a redirect or another 4xx is not (the endpoint said no).
|
|
228
|
-
- The same event can arrive twice (a retry after a timeout you actually handled): dedupe on `event.id`. Order is not guaranteed: order by `createdAt` and the event's `at`.
|
|
229
|
-
- During a secret rotation the header carries one `v1` per valid secret for 24 hours, so either secret verifies
|
|
230
|
-
- "Send test event" in the panel delivers `type: 'webhook.test'` with `number: null
|
|
535
|
+
- **Retries.** Answer 2xx quickly. No answer within 10 seconds, a network error, 408, 429 or 5xx is retried up to 8 times over about a day (after 10 s, 1 min, 5 min, 15 min, 1 h, 3 h, 6 h and 10 h, with some jitter; a `Retry-After` up to an hour is honoured). A redirect or another 4xx is not retried (the endpoint said no). After 3 failures in a row the endpoint is Failing and gets one probe at a time; failing for a day pauses it (resume it in the panel).
|
|
536
|
+
- **Duplicates and order.** The same event can arrive twice (a retry after a timeout you actually handled): dedupe on `event.id`. Order is not guaranteed: order by `createdAt` and the event's `at`. A `presence.updated` older than a minute is not delivered.
|
|
537
|
+
- **Signing.** During a secret rotation the header carries one `v1` per valid secret for 24 hours, so either secret verifies; `secret` also takes an array.
|
|
538
|
+
- "Send test event" in the panel delivers `type: 'webhook.test'` with `number: null` (`WebhookTestEvent`).
|
|
231
539
|
- Message text comes from strangers: treat it as untrusted input.
|
|
232
540
|
|
|
233
541
|
## Errors
|
|
@@ -237,60 +545,196 @@ Every error is a `WhispError`:
|
|
|
237
545
|
| Field | |
|
|
238
546
|
|---|---|
|
|
239
547
|
| `code` | Stable: `NUMBER_NOT_IN_PROJECT`, `RATE_LIMITED`, `TOKEN_MISSING`, ... |
|
|
240
|
-
| `status` | The HTTP status when the API answered; `null` when nothing was sent. |
|
|
241
|
-
| `message` | English, safe to log; never contains your token. |
|
|
242
|
-
| `params` | The values the code names (`number`, `
|
|
548
|
+
| `status` | The HTTP status when the API answered; `null` when nothing was sent (a local check) or no answer came. |
|
|
549
|
+
| `message` | English, safe to log; never contains your token or a webhook secret. |
|
|
550
|
+
| `params` | The values the code names (`number`, `cap`, `reason`, ...), or `null`. |
|
|
243
551
|
| `requestId` | The API's `X-Request-Id`, for support. |
|
|
244
|
-
| `retryAfter` | Seconds the API asked to wait, or `null`. |
|
|
552
|
+
| `retryAfter` | Seconds the API asked to wait (`Retry-After`), or `null`. |
|
|
245
553
|
|
|
246
|
-
`WhispConnectionError` (`NETWORK_ERROR`, `TIMEOUT
|
|
554
|
+
Subclasses: `WhispConnectionError` (`NETWORK_ERROR`, `TIMEOUT`: no answer after every retry; `cause` is the runtime's error) and `WhispSignatureError` (`SIGNATURE_INVALID`, with `reason`). One `instanceof WhispError` catches them all. `SDK_ERROR_CODES` lists the codes the SDK makes itself.
|
|
247
555
|
|
|
248
|
-
|
|
556
|
+
```ts
|
|
557
|
+
import { WhispError } from 'whisp-js'
|
|
558
|
+
|
|
559
|
+
try {
|
|
560
|
+
await support.send(to, text)
|
|
561
|
+
} catch (error) {
|
|
562
|
+
if (error instanceof WhispError && error.code === 'DAILY_CAP_REACHED') {
|
|
563
|
+
await queueForLater(to, text, error.params?.resetAt)
|
|
564
|
+
} else throw error
|
|
565
|
+
}
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### Codes the SDK makes (nothing was sent, or no answer came)
|
|
569
|
+
|
|
570
|
+
| Code | Means | What to do |
|
|
571
|
+
|---|---|---|
|
|
572
|
+
| `TOKEN_MISSING` | No `token` and no `WHISP_API_TOKEN`. | Set it. |
|
|
573
|
+
| `TOKEN_INVALID` | Not shaped like a project token (`wsp_live_...`). The API uses the same code. | Copy it again from the API & SDK tab. |
|
|
574
|
+
| `TOKEN_IN_BROWSER` | The SDK was created in a browser. | Run it on your server. |
|
|
575
|
+
| `PHONE_INVALID` | A `phone` or `number(...)` that is neither international digits nor a `num_` id. | Fix the value. |
|
|
576
|
+
| `NUMBER_REQUIRED` | A one-number method on a pool or the whole project. | Call it on `whisp.number(...)`. |
|
|
577
|
+
| `OPTIONS_INVALID` | An option or argument the SDK can tell is wrong (`baseUrl`, `timeoutMs`, `dailyCap`, an empty emoji, a typing state, ...). | Fix the call. |
|
|
578
|
+
| `MEDIA_INVALID` | A file that can't be uploaded as given (no bytes, no type, too big). | Fix the file or pass `mime`. |
|
|
579
|
+
| `UPLOAD_FAILED` | The upload to storage failed after its retries. | Try again. |
|
|
580
|
+
| `MEDIA_DOWNLOAD_FAILED` | The signed file link answered an error. | Call `downloadMedia` again. |
|
|
581
|
+
| `INVALID_RESPONSE` | A 2xx in an undocumented shape, or a redirect the SDK refused to follow. | Check `baseUrl`. |
|
|
582
|
+
| `NETWORK_ERROR`, `TIMEOUT` | No HTTP answer after every retry; or the event stream was silent for 45 s (it reconnects). | Retry later; a send kept its `Idempotency-Key`, so pass your own key to retry it safely. |
|
|
583
|
+
| `SECRET_MISSING`, `SECRET_INVALID`, `SIGNATURE_INVALID`, `CRYPTO_UNAVAILABLE` | `verifyWebhook` (see [Webhooks](#webhooks)). | |
|
|
584
|
+
|
|
585
|
+
### Codes the API answers
|
|
586
|
+
|
|
587
|
+
| Code | Status | Means | What to do |
|
|
588
|
+
|---|---|---|---|
|
|
589
|
+
| `TOKEN_INVALID` | 401 | Missing, malformed or unknown token. | Copy it again. |
|
|
590
|
+
| `TOKEN_REVOKED` | 401 | Regenerated, or its project deleted. | Use the new token. |
|
|
591
|
+
| `TOKEN_IN_BROWSER` | 403 | The token was used from a browser page; the panel flags it. | Regenerate it and keep it on the server. |
|
|
592
|
+
| `NOT_FOUND` | 404 | No such route: usually an API older than this SDK, answering a 0.3.0 method. | Update the server, or don't call the method yet. |
|
|
593
|
+
| `PAYLOAD_TOO_LARGE` | 413 | A JSON body over 64 KB, or one without a `Content-Length` (the SDK always sends it). | Send files by upload or URL, not inline. |
|
|
594
|
+
| `VALIDATION_FAILED` | 400, 422 | A field the route doesn't take, a bad value, or a body that isn't JSON; `params.reason` names some (`number`, `cursor`, `emoji`, `caption`, `mentions`, `unreadable_body`). | Fix the request. |
|
|
595
|
+
| `RATE_LIMITED` | 429 | Over a limit (see [Limits](#limits-and-guardrails)); `retryAfter` says when. | The SDK waits and retries up to a minute; slow down. |
|
|
596
|
+
| `NUMBER_NOT_IN_PROJECT` | 404 | That phone or id isn't a number of the token's project (`params.number`). | Check `phone`. |
|
|
597
|
+
| `NUMBER_NOT_CONNECTED` | 409 | The number is disconnected (or, for edits, deletes and phone checks, not connected right now). | Wait for `number.status`, or use another number. |
|
|
598
|
+
| `NO_NUMBER_AVAILABLE` | 409 | No number of the pool can send (`params` counts each reason: disconnected, paused, at risk, at its cap). | Add numbers or wait. |
|
|
599
|
+
| `NUMBER_PAUSED` | 409 | Sending from the API is paused on that number (by a member, or automatically when it was at risk). Also an edit or a typing start there. | Send from another number; a member resumes it in the panel. |
|
|
600
|
+
| `DAILY_CAP_REACHED` | 429 | The number sent its daily cap in 24 hours. `params`: `cap`, `used`, `resetAt`. | Wait until `resetAt`, or use a pool. |
|
|
601
|
+
| `SEND_QUEUE_FULL` | 429 | 1,000 messages already wait on that number (for a pool: on every number that could send). | Slow down. |
|
|
602
|
+
| `RECIPIENT_INVALID` | 400 | `to` or `:chat` isn't a phone in international form, a chat id or a group JID. | Fix it. |
|
|
603
|
+
| `CHAT_NOT_FOUND` | 404 | No such chat on that number (reads, `markRead`, typing). | Send first to open it. |
|
|
604
|
+
| `MESSAGE_NOT_FOUND` | 404 | No such message in the project. | |
|
|
605
|
+
| `REPLY_TARGET_NOT_FOUND` | 404 | `replyTo` isn't a message of that chat. | |
|
|
606
|
+
| `GROUPS_IGNORED` | 409 | The number ignores groups (its setting). | |
|
|
607
|
+
| `NOT_IN_GROUP` | 409 | The number left the group or was removed. | |
|
|
608
|
+
| `MESSAGE_NOT_OURS` | 409 | An edit or a delete of someone else's message. | |
|
|
609
|
+
| `MESSAGE_NOT_SENT` | 409 | It never reached WhatsApp (failed, or still waiting its turn): edit, delete or react once it is out. `params.status`. | Retry a failed message in the panel first. |
|
|
610
|
+
| `MESSAGE_NOT_EDITABLE` | 409 | A type WhatsApp can't edit. `params.reason`. | |
|
|
611
|
+
| `MESSAGE_DELETED` | 409 | It was deleted for everyone. | |
|
|
612
|
+
| `EDIT_WINDOW_PASSED`, `REVOKE_WINDOW_PASSED` | 409 | WhatsApp's 15 minutes for an edit, or 2 days for a delete, have passed. `params.sentAt`. | Send a correction instead. |
|
|
613
|
+
| `MENTIONS_NEED_GROUP` | 400 | Mentions in a direct chat. | |
|
|
614
|
+
| `MENTION_NOT_IN_TEXT` | 400 | A mention's `@<digits>` isn't in the text. `params.mention`. | |
|
|
615
|
+
| `MENTION_UNRESOLVED` | 409 | An edit's phone mention a LID-addressed group can't place (a send fails with it as a `message.status` instead). | Mention them by `<digits>@lid`. |
|
|
616
|
+
| `WA_REJECTED` | 400 | WhatsApp refused an edit or a delete; `params.detail` is its text. | |
|
|
617
|
+
| `IDEMPOTENCY_KEY_INVALID` | 400 | An `idempotencyKey` that isn't 1 to 255 printable ASCII characters (no spaces). | |
|
|
618
|
+
| `IDEMPOTENCY_KEY_REUSED` | 409 | The key was used for another body within 24 hours. | Use a new key for a new message. |
|
|
619
|
+
| `MEDIA_URL_NOT_ALLOWED` | 400 | A file URL the server won't fetch (not https, a private address). `params.reason`. | |
|
|
620
|
+
| `UPLOAD_NOT_FOUND`, `UPLOAD_TOO_LARGE`, `UPLOAD_MISMATCH`, `MEDIA_TYPE_NOT_ALLOWED` | 404, 413, 409, 415 | An upload that expired (an hour), is over its kind's limit, changed after it was signed, or a type that kind doesn't take. | Upload again. |
|
|
621
|
+
| `MEDIA_NOT_READY` | 409 | The file is still being prepared (`downloadMedia` waits up to 60 s). | |
|
|
622
|
+
| `MEDIA_VARIANT_NOT_FOUND` | 404 | No thumbnail for this file. `params.variant`. | Ask for `original`. |
|
|
623
|
+
| `TOO_MANY_STREAMS` | 429 | More than 10 open event streams for the token. | Share one `Whisp` instance per process. |
|
|
624
|
+
| `WORKER_UNAVAILABLE` | 503 | An edit, a delete or a phone check got no answer from WhatsApp in time; an edit or a delete may still go through. | Check the message before calling again. |
|
|
625
|
+
| `WORKER_OUTDATED` | 503 | The server is being updated (see below). | Retry in a few minutes. |
|
|
626
|
+
| `NOT_CONFIGURED` | 503 | File storage isn't set up on this server: uploads and downloads can't work. | Tell the server's owner; text sends still work. |
|
|
627
|
+
| `INTERNAL` | 500 | A server error. `requestId` for support. | The SDK retries it. |
|
|
628
|
+
| `HTTP_<status>` | any | An error answer without the API's JSON body (a proxy in front of it, usually). | Check `baseUrl`; 5xx are retried. |
|
|
629
|
+
|
|
630
|
+
### Why a message failed
|
|
631
|
+
|
|
632
|
+
A send the API accepted can still fail later: the message's `status` becomes `failed`, with `error.code` on it and on its `message.status` event.
|
|
633
|
+
|
|
634
|
+
| `error.code` | Means |
|
|
249
635
|
|---|---|
|
|
250
|
-
| `
|
|
251
|
-
| `
|
|
252
|
-
| `
|
|
253
|
-
| `
|
|
254
|
-
| `
|
|
255
|
-
| `
|
|
256
|
-
| `
|
|
257
|
-
| `
|
|
258
|
-
| `
|
|
259
|
-
| `
|
|
260
|
-
| `
|
|
261
|
-
| `
|
|
262
|
-
| `
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
636
|
+
| `NOT_ON_WHATSAPP` | The phone isn't on WhatsApp. |
|
|
637
|
+
| `NUMBER_OFFLINE` | The number stayed offline 6 hours, so the message wasn't sent late. |
|
|
638
|
+
| `NUMBER_PAUSED` | It waited 6 hours on a paused number. |
|
|
639
|
+
| `NUMBER_NOT_CONNECTED` | The number was stopped (logged out, banned, unlinked) with the message queued. |
|
|
640
|
+
| `DAILY_CAP_REACHED` | The number reached its cap while the message was queued. |
|
|
641
|
+
| `GROUPS_IGNORED`, `NOT_IN_GROUP` | The number started ignoring groups, or left the group, while it was queued. |
|
|
642
|
+
| `MENTION_UNRESOLVED` | A phone mention a LID-addressed group can't place. Nothing was sent. |
|
|
643
|
+
| `RECIPIENT_CHECK_FAILED` | The server couldn't check the recipient on WhatsApp (3 tries). |
|
|
644
|
+
| `MEDIA_FETCH_FAILED`, `MEDIA_TOO_LARGE` | The file URL couldn't be fetched (`params.reason`), or the file is over its kind's limit. |
|
|
645
|
+
| `WA_REJECTED`, `MEDIA_REJECTED` | WhatsApp refused the message, or its file. |
|
|
646
|
+
| `ACCOUNT_RESTRICTED` | WhatsApp restricted messages from this number for now. Not retried automatically. |
|
|
647
|
+
| `WA_<code>` | Another error from WhatsApp (`WA_479`). |
|
|
648
|
+
| `SEND_FAILED` | Anything else that ended the send after its retries. |
|
|
649
|
+
|
|
650
|
+
A member can retry a failed message from the panel.
|
|
651
|
+
|
|
652
|
+
### Retries and timeouts
|
|
653
|
+
|
|
654
|
+
Network errors, timeouts, 408, 429 and 5xx (but 501) are retried `maxRetries` times with full-jitter backoff (0.5 s, doubling, at most 8 s), or exactly as long as `Retry-After` asks, up to a minute; longer than that, the call fails at once with `retryAfter` set. Other 4xx are never retried. Every attempt of a send carries the same `Idempotency-Key`. `close()` stops retries that are waiting for their turn.
|
|
655
|
+
|
|
656
|
+
## Limits and guardrails
|
|
657
|
+
|
|
658
|
+
What your app sees of the server's limits:
|
|
659
|
+
|
|
660
|
+
| Limit | Value | Over it |
|
|
270
661
|
|---|---|---|
|
|
271
|
-
|
|
|
272
|
-
|
|
|
273
|
-
|
|
|
274
|
-
|
|
|
275
|
-
|
|
|
276
|
-
|
|
|
277
|
-
|
|
|
278
|
-
|
|
|
279
|
-
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
662
|
+
| Requests, every route | 600 a minute per token | `RATE_LIMITED` (429) |
|
|
663
|
+
| Sends | 120 a minute per token | `RATE_LIMITED` |
|
|
664
|
+
| Phone checks | 30 a minute per token, and 30 uncached lookups a minute per number | `RATE_LIMITED` |
|
|
665
|
+
| Uploads | 60 a minute per token | `RATE_LIMITED` |
|
|
666
|
+
| Reactions, edits, deletes, read marks | 120 a minute per token; edits and deletes 30 a minute per number | `RATE_LIMITED` |
|
|
667
|
+
| Typing (start and stop) | 240 a minute per token | `RATE_LIMITED` |
|
|
668
|
+
| Queued messages | 1,000 per number | `SEND_QUEUE_FULL` (429) |
|
|
669
|
+
| Event streams | 10 open per token | `TOO_MANY_STREAMS` (429) |
|
|
670
|
+
| JSON body | 64 KB | `PAYLOAD_TOO_LARGE` (413) |
|
|
671
|
+
|
|
672
|
+
Answers on a limited bucket carry `RateLimit-Policy` and `RateLimit` headers.
|
|
673
|
+
|
|
674
|
+
**Guardrails** protect each number from looking like spam. A member sets them per number in the panel; they apply to sends from the API and Whisp's own call replies, not to replies typed in the inbox (those get the delay only):
|
|
675
|
+
|
|
676
|
+
- **A random gap** of 1 to 4 seconds between two sends of a number, and at most 18 sends in any minute. A burst of 100 sends to one number goes out over about 5 minutes, in order. Use a pool to go faster.
|
|
677
|
+
- **Typing before the send**: a text of 40 characters or more shows "typing..." for 30 ms a character (1 to 4 seconds) before it goes out; a voice note shows "recording audio..." for its length (1 to 4 seconds). When your app already called `startTyping` in that chat, Whisp sends no typing of its own and waits only what is left of that time.
|
|
678
|
+
- **The daily cap** (600 by default): see [Daily caps](#daily-caps).
|
|
679
|
+
- **The pause**: a member can pause a number, and it pauses itself when its health reaches At risk (`number.risk` with `paused: true`). A paused number refuses new API sends (`NUMBER_PAUSED`) and holds the ones already queued until it is resumed (6 hours at most, then they fail with `NUMBER_PAUSED`).
|
|
680
|
+
- **Health**: `number.risk` and `numbers()` (`risk`, `healthScore`) tell you how WhatsApp is likely to see the number: volume against its usual, messages to people who never wrote, the reply rate, failures, fresh links, disconnects and bursts. Pools route around numbers at risk.
|
|
681
|
+
|
|
682
|
+
Reactions, edits, deletes, read marks and typing never count toward the cap, the gap or health.
|
|
683
|
+
|
|
684
|
+
## Server updates (`WORKER_OUTDATED`)
|
|
685
|
+
|
|
686
|
+
Whisp's server has an API and a worker that talks to WhatsApp, deployed one after the other. For a few minutes during an update, a feature the new API knows may be newer than the running worker. Then the call answers 503 `WORKER_OUTDATED` instead of failing later inside WhatsApp:
|
|
687
|
+
|
|
688
|
+
- In 0.3.0: `react` and `unreact`, `edit`, `revoke`, and sends with `mentions`.
|
|
689
|
+
- The SDK retries it as a server error (twice by default); if it still fails, try again once the update is done.
|
|
690
|
+
- `startTyping` and `stopTyping` never answer it: during the window they show typing once (about 25 seconds, `keptUntil: null`) without the keep-alive.
|
|
691
|
+
- Everything else (plain sends, reads, `markRead`, events) is unaffected.
|
|
284
692
|
|
|
285
693
|
## Security
|
|
286
694
|
|
|
287
695
|
- The SDK refuses to run in a browser: there, every visitor could copy the token and send from your numbers. `dangerouslyAllowBrowser: true` lets it construct, but the API still answers `403 TOKEN_IN_BROWSER` and flags the token in the panel.
|
|
288
696
|
- The token only ever travels in the `Authorization` header to the API's origin. Redirects are never followed with it, and the SDK sends no other identifying header than its `User-Agent` (`whisp-sdk/<version> (<runtime>/<version>)`).
|
|
697
|
+
- File uploads go straight to storage with a signed URL and no token; downloads fetch the signed link without it.
|
|
289
698
|
- Webhook secrets never travel at all: they only sign.
|
|
290
699
|
|
|
291
|
-
##
|
|
700
|
+
## Everything the package exports
|
|
701
|
+
|
|
702
|
+
- `Whisp`, `DEFAULT_TIMEOUT_MS`, `DEFAULT_MAX_RETRIES`, and the option types `WhispOptions`, `SendOptions`, `SendFileOptions`, `SendVoiceOptions`, `EditOptions`, `DownloadOptions`, `MessagesQuery`, `Page`, `PageResult`, `VerifyResult`.
|
|
703
|
+
- `verifyWebhook` and `VerifyWebhookInput`.
|
|
704
|
+
- `WhispError`, `WhispConnectionError`, `WhispSignatureError`, `SDK_ERROR_CODES`, `SdkErrorCode`, `SignatureFailureReason`, `ErrorParams`.
|
|
705
|
+
- The listener's types: `EventHandlers`, `ListenerEvent`, `AnyListenerEvent`, `ListenerName`, `EventMeta`, `StreamClose`, `StreamGap`.
|
|
706
|
+
- `MediaSource`, `MediaUrl`, `MediaFile`.
|
|
707
|
+
- Every API shape: `Message`, `Chat`, `WhatsAppNumber`, `Actor`, `PublicEvent`, `EventData`, `WebhookEvent`, `MarkReadResult`, `TypingOptions`, `TypingResult`, `TypingStartState`, `SendMessageBody`, `PhoneCheck`, and the rest.
|
|
708
|
+
- Constants: `SDK_VERSION`, `SDK_PACKAGE_NAME`, `API_TOKEN_ENV`, `API_URL_ENV`, `DEFAULT_BASE_URL`, `PUBLIC_EVENT_TYPES`, `SIGNATURE_HEADER`, `SIGNATURE_TOLERANCE_SECONDS`, `TOKEN_PREFIX`, `WEBHOOK_SECRET_PREFIX`, `EVENT_CURSOR_PREFIX`, `USER_AGENT_PREFIX`.
|
|
709
|
+
|
|
710
|
+
## Versions
|
|
711
|
+
|
|
712
|
+
The API only grows: a field can be added, never renamed, removed or retyped, and a new value of a union is announced here first.
|
|
713
|
+
|
|
714
|
+
- **0.3.1**: no change to the API or the code. The first release from the repository's new home, `StartAppil/Whisp`.
|
|
715
|
+
- **0.3.0**: `react`, `unreact`, `edit`, `revoke`, `markRead`, `startTyping` and `stopTyping`; `mentions` on text and caption sends, and on every `Message`; the `message.edited` event (0.1.0 skips it). Edits and typing starts are refused on a paused number, a phone mention that a LID-addressed group can't place fails with `MENTION_UNRESOLVED`, and `markRead` clears the chat's unread for your team too, with `queued` read receipts not yet delivered. Against an API older than these methods, each new method answers 404 `NOT_FOUND`.
|
|
716
|
+
- **0.1.0**: the first release: sends (text, files, voice notes, pools, daily caps, idempotency, `wait`), reads, downloads, the event stream, and `verifyWebhook`.
|
|
717
|
+
|
|
718
|
+
## FAQ
|
|
719
|
+
|
|
720
|
+
**How do I make the sender see blue ticks?** Call `markRead(chat)` on the number when your app has handled the message. The ticks go out only when read receipts are on: the number's "Read receipts" setting, or the project's "Send read receipts" when the number follows the project. `readReceipts: 'off'` tells you they are off; `'queued'` means they were handed to WhatsApp's connection (a number that is offline drops them).
|
|
721
|
+
|
|
722
|
+
**How do I show "typing..." while my bot thinks?** `await whisp.number(phone).startTyping(chat)` before you start, then send. The send ends it; there's no need to call `stopTyping` unless you decide not to reply. It lasts at most 2 minutes from the latest start: for a longer job, call `startTyping` again every minute or so (cheap: it sends nothing new within 5 seconds). Use `{ state: 'recording' }` before a voice note.
|
|
723
|
+
|
|
724
|
+
**Does typing make the number look online?** No. It sends WhatsApp's typing state only, never "online". Whether a number shows as online is its "Always online" setting in the panel. Whether the recipient sees "typing..." while the number is offline is up to WhatsApp's app on their side.
|
|
725
|
+
|
|
726
|
+
**Why didn't my message go out at once?** Look at the message's `status` and `error`, and at the number in `numbers()`:
|
|
727
|
+
|
|
728
|
+
- `pending` for a few seconds: the guardrails' gap and typing (1 to 4 seconds each), or other sends of the same number ahead of it.
|
|
729
|
+
- `pending` for longer: the number is reconnecting (`status: 'connecting'`; it goes out when it is back, within 6 hours), or paused (`paused: true`; held until a member resumes it).
|
|
730
|
+
- `failed` with `DAILY_CAP_REACHED`, `NUMBER_PAUSED`, `NUMBER_OFFLINE`: see [Why a message failed](#why-a-message-failed).
|
|
731
|
+
- Refused at once with `NUMBER_PAUSED`, `DAILY_CAP_REACHED` or `NO_NUMBER_AVAILABLE`: the number (or the whole pool) can't take sends now; `number.risk` and `numbers()` say why.
|
|
732
|
+
|
|
733
|
+
**Can I send to someone who never wrote to the number?** Yes, but every such message counts against the number's health (messages to new contacts and the reply rate are risk signals). Keep it to people who expect your message.
|
|
734
|
+
|
|
735
|
+
**How do I find a message I sent from my own data?** Send it with `reference: yourId`, then `messages({ reference: yourId })`; the reference is also on every event about it.
|
|
292
736
|
|
|
293
|
-
|
|
737
|
+
**What if my process restarts while listening?** Save `whisp.cursor` after you handle each event and pass it as `cursor` next time. Dedupe on `event.id`.
|
|
294
738
|
|
|
295
739
|
## License
|
|
296
740
|
|