waba-toolkit 0.2.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/ARCHITECTURE.md +783 -0
- package/README.md +238 -242
- package/dist/index.cli.js +863 -0
- package/dist/index.d.mts +180 -1
- package/dist/index.d.ts +180 -1
- package/dist/index.js +203 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +199 -0
- package/dist/index.mjs.map +1 -1
- package/docs/CLI.md +445 -0
- package/docs/MEDIA.md +576 -0
- package/docs/SENDING.md +635 -0
- package/docs/TROUBLESHOOTING.md +622 -0
- package/docs/WEBHOOKS.md +515 -0
- package/package.json +13 -3
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
# waba-toolkit Architecture
|
|
2
|
+
|
|
3
|
+
A minimal, type-safe npm package for WhatsApp Business API webhook processing and media handling.
|
|
4
|
+
|
|
5
|
+
## Design Decisions
|
|
6
|
+
|
|
7
|
+
| Decision | Choice | Rationale |
|
|
8
|
+
|----------|--------|-----------|
|
|
9
|
+
| Module format | ESM + CJS | Modern ESM primary, CJS for compatibility |
|
|
10
|
+
| HTTP client | Native fetch | Zero dependencies, Node 20+ native support |
|
|
11
|
+
| Auth pattern | Constructor injection | Clean API, token reuse across calls |
|
|
12
|
+
| Media return | Stream (default) + Buffer option | Flexibility for different use cases |
|
|
13
|
+
| Type detection | Discriminated unions | Type-safe narrowing in TypeScript |
|
|
14
|
+
| Node.js | 20+ | Native fetch, modern LTS |
|
|
15
|
+
| Validation | Types only | Zero runtime overhead, compile-time safety |
|
|
16
|
+
| Helper inputs | WebhookPayload | Consistent API, extract from top-level webhook |
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Package Structure
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
waba-toolkit/
|
|
24
|
+
├── src/
|
|
25
|
+
│ ├── index.ts # Main exports
|
|
26
|
+
│ ├── client.ts # WABAClient class
|
|
27
|
+
│ ├── media.ts # Media download logic
|
|
28
|
+
│ ├── errors.ts # Error classes
|
|
29
|
+
│ ├── helpers.ts # Utility helpers
|
|
30
|
+
│ ├── verify.ts # Webhook signature verification
|
|
31
|
+
│ ├── webhooks/
|
|
32
|
+
│ │ ├── index.ts # Webhook exports
|
|
33
|
+
│ │ ├── classify.ts # Webhook type classification
|
|
34
|
+
│ │ └── messages.ts # Message type classification
|
|
35
|
+
│ └── types/
|
|
36
|
+
│ ├── index.ts # Type exports
|
|
37
|
+
│ ├── client.ts # Client options types
|
|
38
|
+
│ ├── media.ts # Media response types
|
|
39
|
+
│ ├── webhooks.ts # Webhook payload types
|
|
40
|
+
│ └── messages.ts # Message type definitions
|
|
41
|
+
├── package.json
|
|
42
|
+
├── tsconfig.json
|
|
43
|
+
├── tsup.config.ts
|
|
44
|
+
└── README.md
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Core API Design
|
|
50
|
+
|
|
51
|
+
> **Note:** Code examples below demonstrate how consumers use the package.
|
|
52
|
+
> Function calls like `handleIncomingMessage()` represent your application logic.
|
|
53
|
+
|
|
54
|
+
### 1. Client Initialization
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
// ESM
|
|
58
|
+
import { WABAClient } from 'waba-toolkit';
|
|
59
|
+
|
|
60
|
+
// CommonJS
|
|
61
|
+
const { WABAClient } = require('waba-toolkit');
|
|
62
|
+
|
|
63
|
+
const client = new WABAClient({
|
|
64
|
+
accessToken: 'your-access-token',
|
|
65
|
+
apiVersion: 'v22.0', // optional, defaults to 'v22.0'
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 2. Media Download
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
// Returns ReadableStream (default)
|
|
73
|
+
const { stream, mimeType, sha256, fileSize } = await client.getMedia(mediaId);
|
|
74
|
+
|
|
75
|
+
// Returns ArrayBuffer
|
|
76
|
+
const { buffer, mimeType, sha256, fileSize } = await client.getMedia(mediaId, {
|
|
77
|
+
asBuffer: true
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 3. Webhook Signature Verification
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import { verifyWebhookSignature } from 'waba-toolkit';
|
|
85
|
+
|
|
86
|
+
// In your webhook handler (Express, Fastify, etc.)
|
|
87
|
+
app.post('/webhook', (req, res) => {
|
|
88
|
+
const signature = req.headers['x-hub-signature-256'];
|
|
89
|
+
const rawBody = req.rawBody; // Must be raw Buffer, not parsed JSON
|
|
90
|
+
|
|
91
|
+
const isValid = verifyWebhookSignature({
|
|
92
|
+
signature,
|
|
93
|
+
rawBody,
|
|
94
|
+
appSecret: process.env.META_APP_SECRET,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!isValid) {
|
|
98
|
+
return res.status(401).send('Invalid signature');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Process verified webhook...
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 4. Webhook Classification (after verification)
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
import { classifyWebhook } from 'waba-toolkit';
|
|
109
|
+
|
|
110
|
+
const result = classifyWebhook(webhookPayload);
|
|
111
|
+
|
|
112
|
+
switch (result.type) {
|
|
113
|
+
case 'message':
|
|
114
|
+
// result.payload is typed as MessageWebhookValue
|
|
115
|
+
handleIncomingMessage(result.payload.messages[0]);
|
|
116
|
+
break;
|
|
117
|
+
case 'status':
|
|
118
|
+
// result.payload is typed as StatusWebhookValue
|
|
119
|
+
updateMessageStatus(result.payload.statuses[0]);
|
|
120
|
+
break;
|
|
121
|
+
case 'call':
|
|
122
|
+
// result.payload is typed as CallWebhookValue
|
|
123
|
+
handleIncomingCall(result.payload.calls[0]);
|
|
124
|
+
break;
|
|
125
|
+
case 'unknown':
|
|
126
|
+
// Unrecognized webhook type - log or ignore
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### 5. Message Type Classification
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
import { classifyMessage } from 'waba-toolkit';
|
|
135
|
+
|
|
136
|
+
const message = webhookPayload.entry[0].changes[0].value.messages[0];
|
|
137
|
+
const result = classifyMessage(message);
|
|
138
|
+
|
|
139
|
+
switch (result.type) {
|
|
140
|
+
case 'text':
|
|
141
|
+
processTextMessage(result.message.text.body);
|
|
142
|
+
break;
|
|
143
|
+
case 'image':
|
|
144
|
+
case 'video':
|
|
145
|
+
case 'document':
|
|
146
|
+
case 'sticker':
|
|
147
|
+
// All media types have: id, mime_type, sha256
|
|
148
|
+
downloadMedia(result.message[result.type].id);
|
|
149
|
+
break;
|
|
150
|
+
case 'audio':
|
|
151
|
+
// Audio has id, mime_type, sha256 + optional voice flag
|
|
152
|
+
const isVoiceNote = result.message.audio.voice ?? false;
|
|
153
|
+
processAudio(result.message.audio.id, isVoiceNote);
|
|
154
|
+
break;
|
|
155
|
+
case 'location':
|
|
156
|
+
showOnMap(result.message.location.latitude, result.message.location.longitude);
|
|
157
|
+
break;
|
|
158
|
+
case 'contacts':
|
|
159
|
+
importContacts(result.message.contacts);
|
|
160
|
+
break;
|
|
161
|
+
case 'interactive':
|
|
162
|
+
// Button replies, list replies, flow responses
|
|
163
|
+
handleInteractiveReply(result.message.interactive);
|
|
164
|
+
break;
|
|
165
|
+
case 'reaction':
|
|
166
|
+
updateReaction(result.message.reaction.emoji);
|
|
167
|
+
break;
|
|
168
|
+
// ... other types
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Type Definitions
|
|
175
|
+
|
|
176
|
+
### Webhook Types (Discriminated Union)
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
export type WebhookClassification =
|
|
180
|
+
| { type: 'message'; payload: MessageWebhookValue }
|
|
181
|
+
| { type: 'status'; payload: StatusWebhookValue }
|
|
182
|
+
| { type: 'call'; payload: CallWebhookValue }
|
|
183
|
+
| { type: 'error'; payload: ErrorWebhookValue }
|
|
184
|
+
| { type: 'unknown'; payload: unknown };
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Message Types (Discriminated Union)
|
|
188
|
+
|
|
189
|
+
```typescript
|
|
190
|
+
// Base fields present on all incoming messages
|
|
191
|
+
interface IncomingMessageBase {
|
|
192
|
+
from: string; // sender's phone number
|
|
193
|
+
id: string; // message ID (use for mark-as-read)
|
|
194
|
+
timestamp: string; // Unix epoch seconds as string
|
|
195
|
+
type: string; // message type
|
|
196
|
+
context?: { // present if reply or forwarded
|
|
197
|
+
from?: string; // original sender (for replies)
|
|
198
|
+
id?: string; // original message ID
|
|
199
|
+
forwarded?: boolean;
|
|
200
|
+
frequently_forwarded?: boolean;
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export type MessageClassification =
|
|
205
|
+
| { type: 'text'; message: TextMessage }
|
|
206
|
+
| { type: 'image'; message: ImageMessage }
|
|
207
|
+
| { type: 'video'; message: VideoMessage }
|
|
208
|
+
| { type: 'audio'; message: AudioMessage }
|
|
209
|
+
| { type: 'document'; message: DocumentMessage }
|
|
210
|
+
| { type: 'sticker'; message: StickerMessage }
|
|
211
|
+
| { type: 'location'; message: LocationMessage }
|
|
212
|
+
| { type: 'contacts'; message: ContactsMessage }
|
|
213
|
+
| { type: 'interactive'; message: InteractiveMessage }
|
|
214
|
+
| { type: 'reaction'; message: ReactionMessage }
|
|
215
|
+
| { type: 'button'; message: ButtonMessage }
|
|
216
|
+
| { type: 'order'; message: OrderMessage }
|
|
217
|
+
| { type: 'system'; message: SystemMessage }
|
|
218
|
+
| { type: 'referral'; message: ReferralMessage }
|
|
219
|
+
| { type: 'unsupported'; message: UnsupportedMessage };
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Media Types
|
|
223
|
+
|
|
224
|
+
> **Note:** The package normalizes WABA's snake_case fields (`mime_type`, `file_size`)
|
|
225
|
+
> to camelCase (`mimeType`, `fileSize`) for idiomatic JavaScript/TypeScript usage.
|
|
226
|
+
|
|
227
|
+
```typescript
|
|
228
|
+
export interface MediaMetadata {
|
|
229
|
+
id: string;
|
|
230
|
+
mimeType: string; // normalized from mime_type
|
|
231
|
+
sha256: string;
|
|
232
|
+
fileSize: number; // normalized from file_size (string → number)
|
|
233
|
+
url: string; // Temporary URL (5 min expiry)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface MediaStreamResult extends MediaMetadata {
|
|
237
|
+
stream: ReadableStream<Uint8Array>;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface MediaBufferResult extends MediaMetadata {
|
|
241
|
+
buffer: ArrayBuffer;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export type MediaResult<T extends { asBuffer?: boolean }> =
|
|
245
|
+
T extends { asBuffer: true } ? MediaBufferResult : MediaStreamResult;
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Webhook Payload Reference
|
|
251
|
+
|
|
252
|
+
Based on WABA API documentation, these are the webhook value types:
|
|
253
|
+
|
|
254
|
+
### Message Webhook Value
|
|
255
|
+
```typescript
|
|
256
|
+
interface MessageWebhookValue {
|
|
257
|
+
messaging_product: 'whatsapp';
|
|
258
|
+
metadata: {
|
|
259
|
+
display_phone_number: string;
|
|
260
|
+
phone_number_id: string;
|
|
261
|
+
};
|
|
262
|
+
contacts?: Array<{
|
|
263
|
+
profile: { name?: string }; // name is optional per WABA docs
|
|
264
|
+
wa_id: string;
|
|
265
|
+
}>;
|
|
266
|
+
messages?: Array<IncomingMessage>;
|
|
267
|
+
errors?: Array<WebhookError>; // present on error webhooks
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Status Webhook Value
|
|
272
|
+
```typescript
|
|
273
|
+
interface StatusWebhookValue {
|
|
274
|
+
messaging_product: 'whatsapp';
|
|
275
|
+
metadata: {
|
|
276
|
+
display_phone_number: string;
|
|
277
|
+
phone_number_id: string;
|
|
278
|
+
};
|
|
279
|
+
statuses: Array<{
|
|
280
|
+
id: string;
|
|
281
|
+
recipient_id: string;
|
|
282
|
+
status: 'sent' | 'delivered' | 'read' | 'failed' | 'deleted';
|
|
283
|
+
timestamp: string;
|
|
284
|
+
conversation?: ConversationObject;
|
|
285
|
+
pricing?: PricingObject;
|
|
286
|
+
errors?: ErrorObject[];
|
|
287
|
+
}>;
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Call Webhook Value
|
|
292
|
+
```typescript
|
|
293
|
+
interface CallWebhookValue {
|
|
294
|
+
messaging_product: 'whatsapp';
|
|
295
|
+
metadata: {
|
|
296
|
+
display_phone_number: string;
|
|
297
|
+
phone_number_id: string;
|
|
298
|
+
};
|
|
299
|
+
contacts?: Array<{
|
|
300
|
+
profile: { name?: string };
|
|
301
|
+
wa_id: string;
|
|
302
|
+
}>;
|
|
303
|
+
calls: Array<{
|
|
304
|
+
id: string;
|
|
305
|
+
from: string;
|
|
306
|
+
to: string;
|
|
307
|
+
event?: 'connect'; // present on connect webhooks
|
|
308
|
+
direction: 'USER_INITIATED' | 'BUSINESS_INITIATED';
|
|
309
|
+
timestamp: string;
|
|
310
|
+
session?: { sdp_type: string; sdp: string };
|
|
311
|
+
status?: string[]; // e.g. ['COMPLETED'] or ['FAILED'] on terminate
|
|
312
|
+
start_time?: string; // present on terminate if connected
|
|
313
|
+
end_time?: string;
|
|
314
|
+
duration?: number; // seconds, present on terminate if connected
|
|
315
|
+
errors?: { code: number; message: string };
|
|
316
|
+
}>;
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Implementation Notes
|
|
323
|
+
|
|
324
|
+
### Media Download Flow
|
|
325
|
+
|
|
326
|
+
1. `GET /v22.0/{mediaId}` → Returns temporary URL + metadata
|
|
327
|
+
2. `GET {temporary_url}` → Returns binary stream/buffer
|
|
328
|
+
|
|
329
|
+
The temporary URL expires after **5 minutes**. The client should:
|
|
330
|
+
- Not cache URLs
|
|
331
|
+
- Retry with fresh URL on 404
|
|
332
|
+
|
|
333
|
+
### Webhook Entry Structure
|
|
334
|
+
|
|
335
|
+
All webhooks follow this envelope:
|
|
336
|
+
```typescript
|
|
337
|
+
{
|
|
338
|
+
object: 'whatsapp_business_account',
|
|
339
|
+
entry: [{
|
|
340
|
+
id: string, // WABA ID
|
|
341
|
+
changes: [{
|
|
342
|
+
value: { ... }, // Type-specific payload
|
|
343
|
+
field: 'messages' | 'account_update' | 'calls' | ...
|
|
344
|
+
}]
|
|
345
|
+
}]
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Classification is based on:
|
|
350
|
+
1. `field` value in changes
|
|
351
|
+
2. Presence of `messages`, `statuses`, or `calls` arrays in value
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## File-by-File Implementation Plan
|
|
356
|
+
|
|
357
|
+
### 1. `src/types/webhooks.ts`
|
|
358
|
+
- Base webhook envelope types
|
|
359
|
+
- MessageWebhookValue, StatusWebhookValue, CallWebhookValue interfaces
|
|
360
|
+
- WebhookClassification discriminated union
|
|
361
|
+
|
|
362
|
+
### 2. `src/types/messages.ts`
|
|
363
|
+
- All 15 message type interfaces (TextMessage, ImageMessage, etc.)
|
|
364
|
+
- MediaObject base interface for image/audio/video/document/sticker
|
|
365
|
+
- MessageClassification discriminated union
|
|
366
|
+
|
|
367
|
+
### 3. `src/types/media.ts`
|
|
368
|
+
- MediaMetadata interface
|
|
369
|
+
- MediaStreamResult, MediaBufferResult interfaces
|
|
370
|
+
- GetMediaOptions type
|
|
371
|
+
|
|
372
|
+
### 4. `src/types/client.ts`
|
|
373
|
+
- WABAClientOptions interface
|
|
374
|
+
- API version literals
|
|
375
|
+
|
|
376
|
+
### 5. `src/client.ts`
|
|
377
|
+
- WABAClient class with constructor injection
|
|
378
|
+
- getMedia() method with stream/buffer option
|
|
379
|
+
- Internal fetch wrapper
|
|
380
|
+
|
|
381
|
+
### 6. `src/webhooks/classify.ts`
|
|
382
|
+
- classifyWebhook() function
|
|
383
|
+
- Returns WebhookClassification discriminated union
|
|
384
|
+
|
|
385
|
+
### 7. `src/webhooks/messages.ts`
|
|
386
|
+
- classifyMessage() function
|
|
387
|
+
- Returns MessageClassification discriminated union
|
|
388
|
+
|
|
389
|
+
### 8. `src/index.ts`
|
|
390
|
+
- Re-export WABAClient, classifyWebhook, classifyMessage
|
|
391
|
+
- Re-export all types
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## Dependencies
|
|
396
|
+
|
|
397
|
+
**Production**: None (uses native fetch)
|
|
398
|
+
|
|
399
|
+
**Development**:
|
|
400
|
+
- `typescript` ^5.x
|
|
401
|
+
- `tsup` ^8.x (zero-config bundler, uses esbuild under the hood)
|
|
402
|
+
- `vitest` for testing (optional)
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## package.json Essentials
|
|
407
|
+
|
|
408
|
+
```json
|
|
409
|
+
{
|
|
410
|
+
"name": "waba-toolkit",
|
|
411
|
+
"version": "0.1.0",
|
|
412
|
+
"main": "./dist/index.js",
|
|
413
|
+
"module": "./dist/index.mjs",
|
|
414
|
+
"types": "./dist/index.d.ts",
|
|
415
|
+
"engines": {
|
|
416
|
+
"node": ">=20.0.0"
|
|
417
|
+
},
|
|
418
|
+
"exports": {
|
|
419
|
+
".": {
|
|
420
|
+
"types": "./dist/index.d.ts",
|
|
421
|
+
"import": "./dist/index.mjs",
|
|
422
|
+
"require": "./dist/index.js"
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
"files": ["dist"],
|
|
426
|
+
"scripts": {
|
|
427
|
+
"build": "tsup",
|
|
428
|
+
"typecheck": "tsc --noEmit",
|
|
429
|
+
"prepublishOnly": "npm run build"
|
|
430
|
+
},
|
|
431
|
+
"devDependencies": {
|
|
432
|
+
"tsup": "^8.0.0",
|
|
433
|
+
"typescript": "^5.0.0"
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
## tsup.config.ts
|
|
439
|
+
|
|
440
|
+
```typescript
|
|
441
|
+
import { defineConfig } from 'tsup';
|
|
442
|
+
|
|
443
|
+
export default defineConfig({
|
|
444
|
+
entry: ['src/index.ts'],
|
|
445
|
+
format: ['esm', 'cjs'],
|
|
446
|
+
dts: true,
|
|
447
|
+
clean: true,
|
|
448
|
+
target: 'node20',
|
|
449
|
+
sourcemap: true,
|
|
450
|
+
});
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
---
|
|
454
|
+
|
|
455
|
+
## Final Decisions
|
|
456
|
+
|
|
457
|
+
| Question | Decision |
|
|
458
|
+
|----------|----------|
|
|
459
|
+
| Error handling | Throw typed errors (WABAError subclasses) |
|
|
460
|
+
| Default API version | v22.0 |
|
|
461
|
+
| Utility helpers | Include all 4 helpers below |
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## WABAClient API
|
|
466
|
+
|
|
467
|
+
### constructor(options)
|
|
468
|
+
|
|
469
|
+
```typescript
|
|
470
|
+
interface WABAClientOptions {
|
|
471
|
+
accessToken: string; // Meta access token with whatsapp_business_messaging permission
|
|
472
|
+
apiVersion?: string; // Default: 'v22.0'
|
|
473
|
+
baseUrl?: string; // Default: 'https://graph.facebook.com'
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const client = new WABAClient({ accessToken: 'your-token' });
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
### getMedia(mediaId, options?)
|
|
480
|
+
|
|
481
|
+
Downloads media from WhatsApp's servers using the two-step Meta API flow.
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
/**
|
|
485
|
+
* Fetches media by ID from WhatsApp Business API.
|
|
486
|
+
*
|
|
487
|
+
* Step 1: GET /{apiVersion}/{mediaId} → retrieves temporary URL + metadata
|
|
488
|
+
* Step 2: GET {temporaryUrl} → downloads binary content
|
|
489
|
+
*
|
|
490
|
+
* @throws {WABAMediaError} - Media not found (404) or access denied
|
|
491
|
+
* @throws {WABANetworkError} - Network/connection failures
|
|
492
|
+
*/
|
|
493
|
+
async getMedia(
|
|
494
|
+
mediaId: string,
|
|
495
|
+
options?: GetMediaOptions
|
|
496
|
+
): Promise<MediaStreamResult | MediaBufferResult>;
|
|
497
|
+
|
|
498
|
+
interface GetMediaOptions {
|
|
499
|
+
asBuffer?: boolean; // Default: false (returns stream)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
interface MediaStreamResult {
|
|
503
|
+
stream: ReadableStream<Uint8Array>;
|
|
504
|
+
mimeType: string;
|
|
505
|
+
sha256: string;
|
|
506
|
+
fileSize: number;
|
|
507
|
+
url: string; // Temporary URL (expires in 5 min, for reference only)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
interface MediaBufferResult {
|
|
511
|
+
buffer: ArrayBuffer;
|
|
512
|
+
mimeType: string;
|
|
513
|
+
sha256: string;
|
|
514
|
+
fileSize: number;
|
|
515
|
+
url: string;
|
|
516
|
+
}
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
**Behavior notes:**
|
|
520
|
+
- Fetches a fresh temporary URL on every call (no caching)
|
|
521
|
+
- Temporary URL expires after 5 minutes
|
|
522
|
+
- On 404, throws `WABAMediaError` - caller can retry (see Retry Patterns)
|
|
523
|
+
- `mimeType` comes from Meta's metadata, not content-type header
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## Utility Helpers
|
|
528
|
+
|
|
529
|
+
**Design Principle**: All helpers accept `WebhookPayload` as input for consistency. This provides a uniform API where users pass the top-level webhook object to any helper function.
|
|
530
|
+
|
|
531
|
+
### getContactInfo(webhook)
|
|
532
|
+
```typescript
|
|
533
|
+
/**
|
|
534
|
+
* Extracts sender info from webhook payload.
|
|
535
|
+
* Returns null if not a message/call webhook or contacts not present.
|
|
536
|
+
*/
|
|
537
|
+
function getContactInfo(webhook: WebhookPayload): {
|
|
538
|
+
waId: string;
|
|
539
|
+
profileName: string | undefined;
|
|
540
|
+
phoneNumberId: string;
|
|
541
|
+
} | null;
|
|
542
|
+
|
|
543
|
+
// Usage
|
|
544
|
+
const contact = getContactInfo(webhookPayload);
|
|
545
|
+
if (contact) {
|
|
546
|
+
await saveToDatabase(contact.waId, contact.profileName);
|
|
547
|
+
}
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### getMessageId(webhook)
|
|
551
|
+
```typescript
|
|
552
|
+
/**
|
|
553
|
+
* Extracts message ID from message or status webhook.
|
|
554
|
+
* Returns null if not a message/status webhook or ID not present.
|
|
555
|
+
*/
|
|
556
|
+
function getMessageId(webhook: WebhookPayload): string | null;
|
|
557
|
+
|
|
558
|
+
// Usage
|
|
559
|
+
const messageId = getMessageId(webhookPayload);
|
|
560
|
+
if (messageId) {
|
|
561
|
+
await markAsRead(messageId);
|
|
562
|
+
}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
### getCallId(webhook)
|
|
566
|
+
```typescript
|
|
567
|
+
/**
|
|
568
|
+
* Extracts call ID from call webhook.
|
|
569
|
+
* Returns null if not a call webhook or ID not present.
|
|
570
|
+
*/
|
|
571
|
+
function getCallId(webhook: WebhookPayload): string | null;
|
|
572
|
+
|
|
573
|
+
// Usage
|
|
574
|
+
const callId = getCallId(webhookPayload);
|
|
575
|
+
if (callId) {
|
|
576
|
+
await logCall(callId);
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
### extractMediaId(message)
|
|
581
|
+
```typescript
|
|
582
|
+
/**
|
|
583
|
+
* Extracts media ID from any media message type.
|
|
584
|
+
* Returns undefined if message has no media.
|
|
585
|
+
*
|
|
586
|
+
* Note: This helper operates on individual message objects,
|
|
587
|
+
* not the webhook payload. Extract message first using
|
|
588
|
+
* webhook.entry[0].changes[0].value.messages[0]
|
|
589
|
+
*/
|
|
590
|
+
function extractMediaId(message: IncomingMessage): string | undefined;
|
|
591
|
+
|
|
592
|
+
// Usage
|
|
593
|
+
const message = webhookPayload.entry[0].changes[0].value.messages?.[0];
|
|
594
|
+
if (message) {
|
|
595
|
+
const mediaId = extractMediaId(message);
|
|
596
|
+
if (mediaId) {
|
|
597
|
+
const media = await client.getMedia(mediaId);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### isMediaMessage(message)
|
|
603
|
+
```typescript
|
|
604
|
+
/**
|
|
605
|
+
* Type guard: returns true if message contains downloadable media.
|
|
606
|
+
* Narrows type to messages with image/audio/video/document/sticker.
|
|
607
|
+
*
|
|
608
|
+
* Note: This helper operates on individual message objects.
|
|
609
|
+
*/
|
|
610
|
+
function isMediaMessage(message: IncomingMessage): message is MediaMessage;
|
|
611
|
+
|
|
612
|
+
// Usage
|
|
613
|
+
const message = webhookPayload.entry[0].changes[0].value.messages?.[0];
|
|
614
|
+
if (message && isMediaMessage(message)) {
|
|
615
|
+
const mediaId = extractMediaId(message); // guaranteed non-undefined
|
|
616
|
+
}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
### getMessageTimestamp(message)
|
|
620
|
+
```typescript
|
|
621
|
+
/**
|
|
622
|
+
* Parses message timestamp to Date object.
|
|
623
|
+
* WhatsApp timestamps are Unix epoch seconds as strings.
|
|
624
|
+
*
|
|
625
|
+
* Note: This helper operates on individual message objects.
|
|
626
|
+
*/
|
|
627
|
+
function getMessageTimestamp(message: IncomingMessage): Date;
|
|
628
|
+
|
|
629
|
+
// Usage
|
|
630
|
+
const message = webhookPayload.entry[0].changes[0].value.messages?.[0];
|
|
631
|
+
if (message) {
|
|
632
|
+
const sentAt = getMessageTimestamp(message);
|
|
633
|
+
await logMessage({ receivedAt: sentAt, messageId: message.id });
|
|
634
|
+
}
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
---
|
|
638
|
+
|
|
639
|
+
## Error Types
|
|
640
|
+
|
|
641
|
+
```typescript
|
|
642
|
+
export class WABAError extends Error {
|
|
643
|
+
constructor(
|
|
644
|
+
message: string,
|
|
645
|
+
public readonly code?: number,
|
|
646
|
+
public readonly details?: unknown
|
|
647
|
+
) {
|
|
648
|
+
super(message);
|
|
649
|
+
this.name = 'WABAError';
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export class WABAMediaError extends WABAError {
|
|
654
|
+
constructor(
|
|
655
|
+
message: string,
|
|
656
|
+
public readonly mediaId: string,
|
|
657
|
+
code?: number
|
|
658
|
+
) {
|
|
659
|
+
super(message, code);
|
|
660
|
+
this.name = 'WABAMediaError';
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export class WABANetworkError extends WABAError {
|
|
665
|
+
constructor(
|
|
666
|
+
message: string,
|
|
667
|
+
public readonly cause?: Error
|
|
668
|
+
) {
|
|
669
|
+
super(message);
|
|
670
|
+
this.name = 'WABANetworkError';
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
---
|
|
676
|
+
|
|
677
|
+
## Webhook Signature Verification
|
|
678
|
+
|
|
679
|
+
Meta requires verifying the `X-Hub-Signature-256` header on all webhook requests. This prevents spoofed webhooks from malicious actors.
|
|
680
|
+
|
|
681
|
+
```typescript
|
|
682
|
+
/**
|
|
683
|
+
* Verifies webhook signature using HMAC-SHA256.
|
|
684
|
+
* Uses timing-safe comparison to prevent timing attacks.
|
|
685
|
+
*/
|
|
686
|
+
function verifyWebhookSignature(options: {
|
|
687
|
+
signature: string | undefined; // X-Hub-Signature-256 header
|
|
688
|
+
rawBody: Buffer | string; // Raw request body (NOT parsed JSON)
|
|
689
|
+
appSecret: string; // Meta App Secret
|
|
690
|
+
}): boolean;
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
**Implementation notes:**
|
|
694
|
+
- Must use raw body bytes, not `JSON.stringify(req.body)` (whitespace differs)
|
|
695
|
+
- Uses `crypto.timingSafeEqual()` to prevent timing attacks
|
|
696
|
+
- Returns `false` if signature header is missing
|
|
697
|
+
|
|
698
|
+
---
|
|
699
|
+
|
|
700
|
+
## Retry Patterns (Documentation)
|
|
701
|
+
|
|
702
|
+
Media URLs expire after **5 minutes**. The `getMedia()` function fetches a fresh URL on each call, so retry is straightforward:
|
|
703
|
+
|
|
704
|
+
```typescript
|
|
705
|
+
import { WABAClient, WABAMediaError } from 'waba-toolkit';
|
|
706
|
+
|
|
707
|
+
const client = new WABAClient({ accessToken: '...' });
|
|
708
|
+
|
|
709
|
+
async function downloadWithRetry(mediaId: string, maxRetries = 2) {
|
|
710
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
711
|
+
try {
|
|
712
|
+
return await client.getMedia(mediaId);
|
|
713
|
+
} catch (e) {
|
|
714
|
+
if (e instanceof WABAMediaError && e.code === 404 && attempt < maxRetries) {
|
|
715
|
+
// URL expired or media not ready, retry with fresh URL
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
throw e;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
**Why not built-in retry?**
|
|
725
|
+
- Retry strategies are application-specific (backoff, max attempts, timeout)
|
|
726
|
+
- Many projects already use retry libraries (`p-retry`, `async-retry`)
|
|
727
|
+
- Keeps the package minimal and unopinionated
|
|
728
|
+
|
|
729
|
+
---
|
|
730
|
+
|
|
731
|
+
## Exported Types
|
|
732
|
+
|
|
733
|
+
All types are exported for user extensions:
|
|
734
|
+
|
|
735
|
+
```typescript
|
|
736
|
+
// Client types
|
|
737
|
+
export type { WABAClientOptions } from './types/client';
|
|
738
|
+
|
|
739
|
+
// Media types
|
|
740
|
+
export type {
|
|
741
|
+
MediaMetadata,
|
|
742
|
+
MediaStreamResult,
|
|
743
|
+
MediaBufferResult,
|
|
744
|
+
GetMediaOptions,
|
|
745
|
+
} from './types/media';
|
|
746
|
+
|
|
747
|
+
// Webhook types
|
|
748
|
+
export type {
|
|
749
|
+
WebhookPayload,
|
|
750
|
+
WebhookEntry,
|
|
751
|
+
WebhookChange,
|
|
752
|
+
MessageWebhookValue,
|
|
753
|
+
StatusWebhookValue,
|
|
754
|
+
CallWebhookValue,
|
|
755
|
+
WebhookClassification,
|
|
756
|
+
} from './types/webhooks';
|
|
757
|
+
|
|
758
|
+
// Message types
|
|
759
|
+
export type {
|
|
760
|
+
IncomingMessage,
|
|
761
|
+
TextMessage,
|
|
762
|
+
ImageMessage,
|
|
763
|
+
AudioMessage,
|
|
764
|
+
VideoMessage,
|
|
765
|
+
DocumentMessage,
|
|
766
|
+
StickerMessage,
|
|
767
|
+
LocationMessage,
|
|
768
|
+
ContactsMessage,
|
|
769
|
+
InteractiveMessage,
|
|
770
|
+
ReactionMessage,
|
|
771
|
+
ButtonMessage,
|
|
772
|
+
OrderMessage,
|
|
773
|
+
SystemMessage,
|
|
774
|
+
ReferralMessage,
|
|
775
|
+
UnsupportedMessage,
|
|
776
|
+
MediaMessage, // Union of all media types
|
|
777
|
+
MessageClassification,
|
|
778
|
+
} from './types/messages';
|
|
779
|
+
|
|
780
|
+
// Error types
|
|
781
|
+
export { WABAError, WABAMediaError, WABANetworkError } from './errors';
|
|
782
|
+
```
|
|
783
|
+
|