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