whisp-js 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +296 -2
- package/dist/cjs/client.d.ts +164 -0
- package/dist/cjs/client.js +460 -0
- package/dist/cjs/constants.d.ts +43 -0
- package/dist/cjs/constants.js +59 -0
- package/dist/cjs/errors.d.ts +96 -0
- package/dist/cjs/errors.js +115 -0
- package/dist/cjs/index.d.ts +19 -0
- package/dist/cjs/index.js +39 -0
- package/dist/cjs/listener.d.ts +93 -0
- package/dist/cjs/listener.js +468 -0
- package/dist/cjs/media.d.ts +40 -0
- package/dist/cjs/media.js +181 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/refs.d.ts +4 -0
- package/dist/cjs/refs.js +38 -0
- package/dist/cjs/runtime.d.ts +24 -0
- package/dist/cjs/runtime.js +93 -0
- package/dist/cjs/sse.d.ts +32 -0
- package/dist/cjs/sse.js +105 -0
- package/dist/cjs/transport.d.ts +61 -0
- package/dist/cjs/transport.js +181 -0
- package/dist/cjs/types.d.ts +516 -0
- package/dist/cjs/types.js +14 -0
- package/dist/cjs/version.d.ts +2 -0
- package/dist/cjs/version.js +5 -0
- package/dist/cjs/webhook.d.ts +27 -0
- package/dist/cjs/webhook.js +128 -0
- package/dist/esm/client.d.ts +164 -0
- package/dist/esm/client.js +456 -0
- package/dist/esm/constants.d.ts +43 -0
- package/dist/esm/constants.js +56 -0
- package/dist/esm/errors.d.ts +96 -0
- package/dist/esm/errors.js +107 -0
- package/dist/esm/index.d.ts +19 -0
- package/dist/esm/index.js +16 -0
- package/dist/esm/listener.d.ts +93 -0
- package/dist/esm/listener.js +463 -0
- package/dist/esm/media.d.ts +40 -0
- package/dist/esm/media.js +175 -0
- package/dist/esm/refs.d.ts +4 -0
- package/dist/esm/refs.js +33 -0
- package/dist/esm/runtime.d.ts +24 -0
- package/dist/esm/runtime.js +84 -0
- package/dist/esm/sse.d.ts +32 -0
- package/dist/esm/sse.js +100 -0
- package/dist/esm/transport.d.ts +61 -0
- package/dist/esm/transport.js +172 -0
- package/dist/esm/types.d.ts +516 -0
- package/dist/esm/types.js +13 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +2 -0
- package/dist/esm/webhook.d.ts +27 -0
- package/dist/esm/webhook.js +123 -0
- package/package.json +37 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 startapp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,297 @@
|
|
|
1
|
-
#
|
|
1
|
+
# whisp-js
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Send WhatsApp messages from your Whisp numbers, read their chats, and react to what happens on them, from your server.
|
|
4
|
+
|
|
5
|
+
- Zero dependencies: `fetch`, Web Streams and Web Crypto only.
|
|
6
|
+
- Node 22.12+ and Bun 1.2+. ESM and CommonJS. Typed.
|
|
7
|
+
- Retries with backoff. Every send carries an `Idempotency-Key`, so a retry never sends twice.
|
|
8
|
+
- `on('message', ...)` over a live event stream that resumes where it stopped.
|
|
9
|
+
- `verifyWebhook` checks the `X-Whisp-Signature` header of webhook requests.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install whisp-js
|
|
15
|
+
# or: bun add whisp-js
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
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.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { Whisp } from 'whisp-js'
|
|
24
|
+
|
|
25
|
+
// Reads WHISP_API_TOKEN when it first needs it.
|
|
26
|
+
const support = new Whisp({ phone: '972501234567' })
|
|
27
|
+
|
|
28
|
+
const message = await support.send('972521112233', 'Your order shipped')
|
|
29
|
+
console.log(message.id, message.status) // msg_..., 'pending'
|
|
30
|
+
|
|
31
|
+
support.on('message', msg => {
|
|
32
|
+
console.log(`${msg.from} wrote to ${msg.number}: ${msg.text}`)
|
|
33
|
+
})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Phones are WhatsApp digits in international form: `972501234567`. The SDK also takes `+972 50-123-4567` and turns it into digits.
|
|
37
|
+
|
|
38
|
+
## Which number sends
|
|
39
|
+
|
|
40
|
+
| `phone` | Sends from |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `'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: `strategy: 'healthiest'` (the default), `'random'` or `'round-robin'`. |
|
|
44
|
+
| left out | The project's default number (Settings), else every number of the project as a pool. |
|
|
45
|
+
|
|
46
|
+
`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
|
+
|
|
48
|
+
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
|
+
|
|
50
|
+
### Daily caps
|
|
51
|
+
|
|
52
|
+
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:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const otp = new Whisp({ phone: ['972501234567', '972501234568'], dailyCap: 300, sticky: false })
|
|
56
|
+
await otp.send('972521112233', 'Your code is 482913') // from the healthiest number that sent under 300 in 24 h
|
|
57
|
+
await otp.send('972521112233', 'Your code is 115720', { dailyCap: 100 }) // this send only
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`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
|
+
|
|
62
|
+
`whisp.number(phoneOrId)` returns a copy that sends from, reads and listens on that one number:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const whisp = new Whisp() // the whole project
|
|
66
|
+
const support = whisp.number('num_7Hq2kD3x')
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
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). `await whisp.verify()` checks the token and that every phone you named belongs to the project, and throws `NUMBER_NOT_IN_PROJECT` listing the ones that don't.
|
|
70
|
+
|
|
71
|
+
## Sending
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
await whisp.send('972521112233', 'Hello from Whisp', {
|
|
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
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`send` resolves the message as the API created it: `pending` until the number sends it, or its status after `wait` (`sent`, `failed`). A message that can't go out (the phone isn't on WhatsApp, the number stayed offline) ends as a `message.status` event with `status: 'failed'` and an `error.code`, such as `NOT_ON_WHATSAPP`.
|
|
84
|
+
|
|
85
|
+
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 key 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`.
|
|
86
|
+
|
|
87
|
+
### Files
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// A public https URL: the server downloads it.
|
|
91
|
+
await whisp.sendImage('972521112233', { url: 'https://example.com/receipt.png' }, { caption: 'Your receipt' })
|
|
92
|
+
|
|
93
|
+
// Bytes you hold: the SDK uploads them first.
|
|
94
|
+
await whisp.sendDocument('972521112233', { file: pdfBytes, fileName: 'invoice.pdf' })
|
|
95
|
+
await whisp.sendVoice('972521112233', { file: Bun.file('note.ogg') })
|
|
96
|
+
await whisp.sendVideo('972521112233', { file: await fs.openAsBlob('clip.mp4'), mime: 'video/mp4' })
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`file` is a `Blob` (a `File`, `Bun.file()`, Node's `fs.openAsBlob()`), a `Uint8Array`, an `ArrayBuffer` or a `ReadableStream`. 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. 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).
|
|
100
|
+
|
|
101
|
+
`sendMessage(body)` sends a raw `POST /messages` body for anything the helpers don't cover; the instance's `phone`, `strategy`, `sticky` and `dailyCap` fill in what the body leaves out.
|
|
102
|
+
|
|
103
|
+
## Reading
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const numbers = await whisp.numbers() // every number of the project, any status
|
|
107
|
+
const message = await whisp.message('msg_...')
|
|
108
|
+
const sent = await whisp.messages({ reference: 'order-1042' }) // newest first, at most 50
|
|
109
|
+
|
|
110
|
+
const support = whisp.number('972501234567')
|
|
111
|
+
const { items, nextCursor } = await support.chats({ unread: true, limit: 20 })
|
|
112
|
+
for await (const msg of support.chatMessages('972521112233')) {
|
|
113
|
+
// newest first, page after page
|
|
114
|
+
}
|
|
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
|
+
```
|
|
118
|
+
|
|
119
|
+
`chats`, `chat`, `chatMessages` and `check` read one number: on a pool or a whole-project instance they throw `NUMBER_REQUIRED`, so call them on `whisp.number(...)`. Awaiting a list gives one page; `for await` walks every page.
|
|
120
|
+
|
|
121
|
+
### Downloading a message's file
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
const response = await whisp.downloadMedia(message) // a fetch Response
|
|
125
|
+
await Bun.write('photo.jpg', response)
|
|
126
|
+
```
|
|
127
|
+
|
|
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.
|
|
129
|
+
|
|
130
|
+
## Listening
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
const whisp = new Whisp({ phone: '972501234567', cursor: await loadCursor() })
|
|
134
|
+
|
|
135
|
+
whisp.on('message', async msg => {
|
|
136
|
+
await handleReply(msg.from, msg.text)
|
|
137
|
+
await saveCursor(whisp.cursor) // resume after this event next time
|
|
138
|
+
})
|
|
139
|
+
whisp.on('message.status', e => console.log(e.messageId, e.previousStatus, '->', e.status))
|
|
140
|
+
whisp.on('number.status', e => console.log(e.number, e.status))
|
|
141
|
+
whisp.on('gap', gap => reconcile(gap.reason)) // events may have been missed: catch up with reads
|
|
142
|
+
whisp.on('error', (error, event) => console.error(error, event?.event.id))
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
| Name | When |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `message` | `message.received`: a new inbound message. |
|
|
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. |
|
|
154
|
+
|
|
155
|
+
A handler gets the event's data with the envelope folded in: `msg.from`, `msg.text`, `msg.number` (the Whisp number's phone), `msg.numberId`, and `msg.event` with the event's `id`, `type`, `createdAt` and `cursor`. `on` returns a function that removes the handler.
|
|
156
|
+
|
|
157
|
+
- **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`) or a number isn't the project's (`NUMBER_NOT_IN_PROJECT`).
|
|
161
|
+
- **Long-lived processes.** The stream suits a server or a worker that stays up. In serverless functions, use webhooks instead.
|
|
162
|
+
|
|
163
|
+
## Webhooks
|
|
164
|
+
|
|
165
|
+
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:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
import { verifyWebhook, WhispSignatureError } from 'whisp-js'
|
|
169
|
+
|
|
170
|
+
const event = await verifyWebhook({
|
|
171
|
+
body: rawBody, // string, Uint8Array or ArrayBuffer
|
|
172
|
+
signature: request.headers.get('x-whisp-signature'),
|
|
173
|
+
secret: process.env.WHISP_WEBHOOK_SECRET,
|
|
174
|
+
toleranceSeconds: 300, // the default
|
|
175
|
+
})
|
|
176
|
+
if (event.type === 'message.received') console.log(event.data.from, event.data.text)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
It throws `WhispSignatureError` with `reason` `'missing'`, `'malformed'`, `'timestamp'` (older than the tolerance) 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.
|
|
180
|
+
|
|
181
|
+
**Express**: the raw body, not `express.json()`:
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
app.post('/whisp', express.raw({ type: 'application/json' }), async (req, res) => {
|
|
185
|
+
try {
|
|
186
|
+
const event = await verifyWebhook({ body: req.body, signature: req.get('x-whisp-signature'), secret: process.env.WHISP_WEBHOOK_SECRET })
|
|
187
|
+
await handle(event)
|
|
188
|
+
res.sendStatus(200)
|
|
189
|
+
} catch (error) {
|
|
190
|
+
res.sendStatus(error instanceof WhispSignatureError ? 400 : 500)
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**Next.js route handlers**: `await request.text()`:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
export async function POST(request: Request) {
|
|
199
|
+
const event = await verifyWebhook({ body: await request.text(), signature: request.headers.get('x-whisp-signature'), secret: process.env.WHISP_WEBHOOK_SECRET })
|
|
200
|
+
await handle(event)
|
|
201
|
+
return new Response(null, { status: 200 })
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Hono**: `await c.req.text()`:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
app.post('/whisp', async c => {
|
|
209
|
+
const event = await verifyWebhook({ body: await c.req.text(), signature: c.req.header('x-whisp-signature'), secret: c.env.WHISP_WEBHOOK_SECRET })
|
|
210
|
+
await handle(event)
|
|
211
|
+
return c.body(null, 200)
|
|
212
|
+
})
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Bun.serve**:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
Bun.serve({
|
|
219
|
+
async fetch(request) {
|
|
220
|
+
const event = await verifyWebhook({ body: await request.text(), signature: request.headers.get('x-whisp-signature'), secret: Bun.env.WHISP_WEBHOOK_SECRET })
|
|
221
|
+
await handle(event)
|
|
222
|
+
return new Response(null, { status: 200 })
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
```
|
|
226
|
+
|
|
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. `secret` also takes an array.
|
|
230
|
+
- "Send test event" in the panel delivers `type: 'webhook.test'` with `number: null`.
|
|
231
|
+
- Message text comes from strangers: treat it as untrusted input.
|
|
232
|
+
|
|
233
|
+
## Errors
|
|
234
|
+
|
|
235
|
+
Every error is a `WhispError`:
|
|
236
|
+
|
|
237
|
+
| Field | |
|
|
238
|
+
|---|---|
|
|
239
|
+
| `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`, `total`, `reason`, ...), or `null`. |
|
|
243
|
+
| `requestId` | The API's `X-Request-Id`, for support. |
|
|
244
|
+
| `retryAfter` | Seconds the API asked to wait, or `null`. |
|
|
245
|
+
|
|
246
|
+
`WhispConnectionError` (`NETWORK_ERROR`, `TIMEOUT`) means no answer after every retry. `WhispSignatureError` comes from `verifyWebhook`.
|
|
247
|
+
|
|
248
|
+
| Code | Means |
|
|
249
|
+
|---|---|
|
|
250
|
+
| `TOKEN_MISSING` | No `token` and no `WHISP_API_TOKEN`. |
|
|
251
|
+
| `TOKEN_INVALID`, `TOKEN_REVOKED` | Not a token of ours, or regenerated (or its project deleted). |
|
|
252
|
+
| `TOKEN_IN_BROWSER` | The token was used from a browser. Regenerate it. |
|
|
253
|
+
| `PHONE_INVALID` | A `phone` that is neither international digits nor a `num_` id. |
|
|
254
|
+
| `NUMBER_REQUIRED` | A read of one number on a pool or the whole project. |
|
|
255
|
+
| `NUMBER_NOT_IN_PROJECT` | That phone or id isn't a number of the token's project. |
|
|
256
|
+
| `NUMBER_NOT_CONNECTED`, `NO_NUMBER_AVAILABLE` | The number is disconnected, or no number of the pool can send (disconnected, paused, at risk or at its daily cap; `params` counts each). |
|
|
257
|
+
| `NUMBER_PAUSED` | Sending from the API is paused on that number (by a member, or automatically when it was at risk). Send from another number. |
|
|
258
|
+
| `DAILY_CAP_REACHED` | The number sent its daily cap in the last 24 hours (its own, or your `dailyCap` when that is lower). `params`: `cap`, `used`, `resetAt`; `retryAfter` says when it can send again. |
|
|
259
|
+
| `GROUPS_IGNORED`, `NOT_IN_GROUP` | A send to a group of a number that ignores groups, or that is no longer in the group. |
|
|
260
|
+
| `RECIPIENT_INVALID` | `to` isn't a phone in international form, a chat id or a group JID. |
|
|
261
|
+
| `RATE_LIMITED`, `SEND_QUEUE_FULL` | Too many requests or sends (for a pool, every number that could send has a full queue); see `retryAfter`. |
|
|
262
|
+
| `IDEMPOTENCY_KEY_REUSED` | The key was used for another body. |
|
|
263
|
+
| `MEDIA_INVALID`, `UPLOAD_FAILED`, `MEDIA_URL_NOT_ALLOWED` | A file the SDK couldn't upload, or a URL the server won't fetch. |
|
|
264
|
+
|
|
265
|
+
## Retries and timeouts
|
|
266
|
+
|
|
267
|
+
Network errors, timeouts, 408, 429 and 5xx are retried 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.
|
|
268
|
+
|
|
269
|
+
| Option | Default | |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| `token` | `WHISP_API_TOKEN` | The project token (`wsp_live_...`), read when a call needs it. |
|
|
272
|
+
| `phone` | the whole project | A phone, a `num_` id, or an array of them (a pool, at most 20). |
|
|
273
|
+
| `strategy` | `'healthiest'` | `'healthiest'`, `'random'` or `'round-robin'`, for pools. |
|
|
274
|
+
| `sticky` | `true` | Keep a recipient on one number. |
|
|
275
|
+
| `dailyCap` | none | 1 to 10,000: send from a number only while it sent fewer in 24 hours. See Daily caps. |
|
|
276
|
+
| `baseUrl` | `WHISP_API_URL`, else `https://api.whisp.lue.my` | |
|
|
277
|
+
| `timeoutMs` | `30000` | Per attempt. A send with `wait` gets the wait on top. |
|
|
278
|
+
| `maxRetries` | `2` | Attempts after the first. |
|
|
279
|
+
| `cursor` | none | Resume the event stream after this cursor. |
|
|
280
|
+
| `fetch` | the global `fetch` | Any WHATWG fetch. |
|
|
281
|
+
| `dangerouslyAllowBrowser` | `false` | See below. |
|
|
282
|
+
|
|
283
|
+
`close()` ends the stream and stops retries that are waiting for their turn.
|
|
284
|
+
|
|
285
|
+
## Security
|
|
286
|
+
|
|
287
|
+
- 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
|
+
- 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>)`).
|
|
289
|
+
- Webhook secrets never travel at all: they only sign.
|
|
290
|
+
|
|
291
|
+
## Runtimes
|
|
292
|
+
|
|
293
|
+
Node 22.12+ and Bun 1.2+, as ESM (`import`) or CommonJS (`require`). Deno and edge runtimes can send and read; `on()` needs a process that stays up.
|
|
294
|
+
|
|
295
|
+
## License
|
|
296
|
+
|
|
297
|
+
MIT
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { type EventHandlers } from './listener.js';
|
|
2
|
+
import { type MediaSource } from './media.js';
|
|
3
|
+
import { type Timers } from './runtime.js';
|
|
4
|
+
import type { Chat, ChatListQuery, ChatMessagesQuery, Message, PhoneCheck, ProjectInfo, PublicEventType, SendMessageBody, SendStrategy, WebhookEvent, WhatsAppNumber } from './types.js';
|
|
5
|
+
import { type VerifyWebhookInput } from './webhook.js';
|
|
6
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
7
|
+
export declare const DEFAULT_MAX_RETRIES = 2;
|
|
8
|
+
/** `downloadMedia` waits for a file being prepared at most this long. */
|
|
9
|
+
export declare const MEDIA_WAIT_MAX_MS = 60000;
|
|
10
|
+
export interface WhispOptions {
|
|
11
|
+
/** The project token (`wsp_live_…`). Default: WHISP_API_TOKEN, read on each call until set. */
|
|
12
|
+
token?: string;
|
|
13
|
+
/** Phones (`972501234567`, `+972 50-123-4567`) or number ids (`num_7Hq2kD3x`); none = the whole project. */
|
|
14
|
+
phone?: string | readonly string[];
|
|
15
|
+
/**
|
|
16
|
+
* How a pool picks its sender: 'healthiest' (the server's default), 'random' or 'round-robin'. Only used
|
|
17
|
+
* with several numbers or none.
|
|
18
|
+
*/
|
|
19
|
+
strategy?: SendStrategy;
|
|
20
|
+
/** Keep a recipient on the number that last talked to them. Default true. */
|
|
21
|
+
sticky?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Send from a number only while it has sent fewer than this many messages in the last 24 hours (1 to
|
|
24
|
+
* 10,000), on every send of this instance. A number's own daily cap still applies: the lower one wins. The
|
|
25
|
+
* server counts and enforces it; a pool skips numbers at their cap, a single number answers
|
|
26
|
+
* DAILY_CAP_REACHED.
|
|
27
|
+
*/
|
|
28
|
+
dailyCap?: number;
|
|
29
|
+
/** Default: WHISP_API_URL, else https://api.whisp.lue.my. */
|
|
30
|
+
baseUrl?: string;
|
|
31
|
+
/** Per attempt. Default 30,000 ms. */
|
|
32
|
+
timeoutMs?: number;
|
|
33
|
+
/** Attempts after the first, for network errors, timeouts, 429 and 5xx. Default 2. */
|
|
34
|
+
maxRetries?: number;
|
|
35
|
+
/** Resume the event stream after this cursor (a saved `whisp.cursor`). */
|
|
36
|
+
cursor?: string;
|
|
37
|
+
/** Any WHATWG fetch. Default: the global one, looked up on each call. */
|
|
38
|
+
fetch?: typeof fetch;
|
|
39
|
+
/**
|
|
40
|
+
* The SDK refuses to run in a browser, where every visitor could copy the
|
|
41
|
+
* token. Even with this set, the API answers 403 TOKEN_IN_BROWSER.
|
|
42
|
+
*/
|
|
43
|
+
dangerouslyAllowBrowser?: boolean;
|
|
44
|
+
}
|
|
45
|
+
/** Test hooks; not part of the published options. */
|
|
46
|
+
export interface InternalWhispOptions extends WhispOptions {
|
|
47
|
+
timers?: Timers;
|
|
48
|
+
/** Where errors go when nobody listens to `'error'`. Default console.error. */
|
|
49
|
+
report?: (error: Error) => void;
|
|
50
|
+
}
|
|
51
|
+
export interface SendOptions {
|
|
52
|
+
/** A `msg_` id or a WhatsApp id of a message in the same chat. */
|
|
53
|
+
replyTo?: string;
|
|
54
|
+
/** Your own id for this send (1-128 characters), echoed on the message and its events. */
|
|
55
|
+
reference?: string;
|
|
56
|
+
/** Wait for the outcome: true is 15 s, or a number of ms from 1,000 to 20,000. */
|
|
57
|
+
wait?: boolean | number;
|
|
58
|
+
/** This send's daily cap (1 to 10,000), in place of the instance's `dailyCap`. */
|
|
59
|
+
dailyCap?: number;
|
|
60
|
+
/** Makes retries safe across your own restarts too. Default: a random UUID per call, kept across its retries. */
|
|
61
|
+
idempotencyKey?: string;
|
|
62
|
+
}
|
|
63
|
+
export interface SendFileOptions extends SendOptions {
|
|
64
|
+
caption?: string;
|
|
65
|
+
}
|
|
66
|
+
export interface MessagesQuery {
|
|
67
|
+
reference: string;
|
|
68
|
+
}
|
|
69
|
+
export interface DownloadOptions {
|
|
70
|
+
variant?: 'original' | 'thumb';
|
|
71
|
+
}
|
|
72
|
+
/** One page of a list, and the cursor of the next one (null on the last). */
|
|
73
|
+
export interface Page<T> {
|
|
74
|
+
items: T[];
|
|
75
|
+
nextCursor: string | null;
|
|
76
|
+
}
|
|
77
|
+
/** Await it for the first page, or `for await` it for every item across pages. */
|
|
78
|
+
export type PageResult<T> = Promise<Page<T>> & AsyncIterable<T>;
|
|
79
|
+
export interface VerifyResult extends ProjectInfo {
|
|
80
|
+
numbers: WhatsAppNumber[];
|
|
81
|
+
}
|
|
82
|
+
export declare class Whisp {
|
|
83
|
+
#private;
|
|
84
|
+
constructor(options?: WhispOptions);
|
|
85
|
+
/**
|
|
86
|
+
* The cursor after the last event delivered to handlers (6.5): save it and
|
|
87
|
+
* pass it as `cursor` to resume after a restart. Not an event's `evt_` id,
|
|
88
|
+
* which is `event.event.id` in a handler. Before the first event it is
|
|
89
|
+
* where the stream started (its `ready`), so a save then resumes from
|
|
90
|
+
* there; null until the stream first opens, unless `cursor` was given
|
|
91
|
+
* (sdk-reconnect-without-cursor-loses-events).
|
|
92
|
+
*/
|
|
93
|
+
get cursor(): string | null;
|
|
94
|
+
/** A copy that sends from, reads and listens on this one number (a phone or a `num_` id). */
|
|
95
|
+
number(phoneOrId: string): Whisp;
|
|
96
|
+
/**
|
|
97
|
+
* Checks the token and the instance's numbers (GET /project, then GET
|
|
98
|
+
* /numbers): throws NUMBER_NOT_IN_PROJECT listing every phone or id that
|
|
99
|
+
* isn't the token's project's. Optional: the first send or listen reports
|
|
100
|
+
* the same.
|
|
101
|
+
*/
|
|
102
|
+
verify(): Promise<VerifyResult>;
|
|
103
|
+
/** Sends text (`POST /messages`) from the instance's number, pool or project. Resolves the message (`pending`, or its status after `wait`). */
|
|
104
|
+
send(to: string, text: string, options?: SendOptions): Promise<Message>;
|
|
105
|
+
sendImage(to: string, media: MediaSource, options?: SendFileOptions): Promise<Message>;
|
|
106
|
+
sendVideo(to: string, media: MediaSource, options?: SendFileOptions): Promise<Message>;
|
|
107
|
+
sendDocument(to: string, media: MediaSource, options?: SendFileOptions): Promise<Message>;
|
|
108
|
+
sendVoice(to: string, media: MediaSource, options?: SendOptions): Promise<Message>;
|
|
109
|
+
/**
|
|
110
|
+
* `POST /messages` with a body you build (5.6). The instance's `from`,
|
|
111
|
+
* `strategy`, `sticky` and `dailyCap` fill in what the body leaves out.
|
|
112
|
+
*/
|
|
113
|
+
sendMessage(body: SendMessageBody, options?: Pick<SendOptions, 'idempotencyKey'>): Promise<Message>;
|
|
114
|
+
/** Every number of the project, any status. */
|
|
115
|
+
numbers(): Promise<WhatsAppNumber[]>;
|
|
116
|
+
/** One message of the project's numbers; 404 MESSAGE_NOT_FOUND otherwise. */
|
|
117
|
+
message(messageId: string): Promise<Message>;
|
|
118
|
+
/** What earlier sends with this `reference` made, newest first, at most 50. */
|
|
119
|
+
messages(query: MessagesQuery): Promise<Message[]>;
|
|
120
|
+
/** Is this phone on WhatsApp, asked through the instance's number. */
|
|
121
|
+
check(phone: string): Promise<PhoneCheck>;
|
|
122
|
+
/** The number's chats, newest activity first. */
|
|
123
|
+
chats(query?: ChatListQuery): PageResult<Chat>;
|
|
124
|
+
/** One chat: a phone, a `cht_` id or a group JID (a merged-away id resolves to the chat it joined). */
|
|
125
|
+
chat(chat: string): Promise<Chat>;
|
|
126
|
+
/**
|
|
127
|
+
* A chat's messages, newest first, history included. Each next page is
|
|
128
|
+
* older (`nextCursor` goes in `before`); after an `after` request, each next
|
|
129
|
+
* page is newer (`nextCursor` goes in `after`).
|
|
130
|
+
*/
|
|
131
|
+
chatMessages(chat: string, query?: ChatMessagesQuery): PageResult<Message>;
|
|
132
|
+
/**
|
|
133
|
+
* A message's file as a fetch `Response` (12.3). Asks the API for a
|
|
134
|
+
* 5-minute link with the token, then fetches the link with no token and no
|
|
135
|
+
* redirects. A file still being prepared (409 MEDIA_NOT_READY with
|
|
136
|
+
* Retry-After) is waited for, up to 60 s.
|
|
137
|
+
*/
|
|
138
|
+
downloadMedia(message: Message | string, options?: DownloadOptions): Promise<Response>;
|
|
139
|
+
/**
|
|
140
|
+
* Calls `handler` for each event of this name (12.4): any of the twelve
|
|
141
|
+
* types, `'message'` (= `message.received`), `'*'`, or the stream's own
|
|
142
|
+
* `'open'`, `'close'`, `'gap'` and `'error'`. The first event handler
|
|
143
|
+
* opens the stream. Returns `off()`.
|
|
144
|
+
*/
|
|
145
|
+
on(event: '*', handler: EventHandlers['*']): () => void;
|
|
146
|
+
on(event: 'message', handler: EventHandlers['message']): () => void;
|
|
147
|
+
on(event: 'open', handler: EventHandlers['open']): () => void;
|
|
148
|
+
on(event: 'close', handler: EventHandlers['close']): () => void;
|
|
149
|
+
on(event: 'gap', handler: EventHandlers['gap']): () => void;
|
|
150
|
+
on(event: 'error', handler: EventHandlers['error']): () => void;
|
|
151
|
+
on<T extends PublicEventType>(event: T, handler: EventHandlers[T]): () => void;
|
|
152
|
+
/** Removes a handler; the stream closes with the last event handler. */
|
|
153
|
+
off(event: '*', handler: EventHandlers['*']): void;
|
|
154
|
+
off(event: 'message', handler: EventHandlers['message']): void;
|
|
155
|
+
off(event: 'open', handler: EventHandlers['open']): void;
|
|
156
|
+
off(event: 'close', handler: EventHandlers['close']): void;
|
|
157
|
+
off(event: 'gap', handler: EventHandlers['gap']): void;
|
|
158
|
+
off(event: 'error', handler: EventHandlers['error']): void;
|
|
159
|
+
off<T extends PublicEventType>(event: T, handler: EventHandlers[T]): void;
|
|
160
|
+
/** Ends the stream, forgets every handler, and stops retries still waiting for their turn. */
|
|
161
|
+
close(): void;
|
|
162
|
+
/** The standalone `verifyWebhook`, for convenience. */
|
|
163
|
+
verifyWebhook(input: VerifyWebhookInput): Promise<WebhookEvent>;
|
|
164
|
+
}
|