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.
@@ -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
+