whatsapp-cloud 0.0.5 → 0.0.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # whatzapp
2
2
 
3
+ ## 0.0.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 2024d31: add audio and image handler
8
+ - f2501c2: add template types
9
+
10
+ ## 0.0.6
11
+
12
+ ### Patch Changes
13
+
14
+ - a46c084: export types
15
+ - 175d774: add webhooks namespace
16
+
3
17
  ## 0.0.5
4
18
 
5
19
  ### Patch Changes
@@ -0,0 +1,154 @@
1
+ # Development Guide
2
+
3
+ ## Local Development with npm/pnpm link
4
+
5
+ To test the SDK in your own project without publishing to npm:
6
+
7
+ ### Step 1: Link the package (in whatsapp-cloud directory)
8
+
9
+ ```bash
10
+ cd /Users/lukas/Developer/whatsapp-cloud
11
+ pnpm build # Build the package first
12
+ pnpm link --global # Creates a global symlink
13
+ ```
14
+
15
+ Or with npm:
16
+
17
+ ```bash
18
+ npm link
19
+ ```
20
+
21
+ ### Step 2: Link in your project
22
+
23
+ ```bash
24
+ cd /path/to/your/project
25
+ pnpm link whatsapp-cloud
26
+ ```
27
+
28
+ Or with npm:
29
+
30
+ ```bash
31
+ npm link whatsapp-cloud
32
+ ```
33
+
34
+ **What this does:** Creates a symlink in your project's `node_modules` pointing to your local package. You don't need to:
35
+
36
+ - Add it to `package.json` (link handles it)
37
+ - Run `pnpm install` (link is enough)
38
+ - Publish to npm
39
+
40
+ ### Step 3: Use it in your project
41
+
42
+ ```typescript
43
+ import { WhatsAppClient, type IncomingTextMessage } from "whatsapp-cloud";
44
+
45
+ const client = new WhatsAppClient({
46
+ accessToken: "...",
47
+ });
48
+ ```
49
+
50
+ ### Important Notes
51
+
52
+ - **Rebuild after changes**: After making changes to whatsapp-cloud, run `pnpm build` in the whatsapp-cloud directory
53
+ - **Hot reload**: Some bundlers (like Next.js) may need a restart to pick up changes
54
+ - **Unlink**: When done, unlink with `pnpm unlink whatsapp-cloud` in your project
55
+
56
+ ## Production / CI/CD
57
+
58
+ For production builds and CI/CD, you have a few options:
59
+
60
+ ### Option 1: Publish to npm (Recommended)
61
+
62
+ Once ready, publish the package:
63
+
64
+ ```bash
65
+ cd /Users/lukas/Developer/whatsapp-cloud
66
+ pnpm publish
67
+ ```
68
+
69
+ Then in your project's `package.json`:
70
+
71
+ ```json
72
+ {
73
+ "dependencies": {
74
+ "whatsapp-cloud": "^0.0.5"
75
+ }
76
+ }
77
+ ```
78
+
79
+ ### Option 2: Git dependency (For private repos)
80
+
81
+ If your repo is private, use git dependency:
82
+
83
+ ```json
84
+ {
85
+ "dependencies": {
86
+ "whatsapp-cloud": "git+https://github.com/your-username/whatsapp-cloud.git"
87
+ }
88
+ }
89
+ ```
90
+
91
+ ### Option 3: Local file path (For monorepos)
92
+
93
+ If both projects are in the same repo/monorepo:
94
+
95
+ ```json
96
+ {
97
+ "dependencies": {
98
+ "whatsapp-cloud": "file:../whatsapp-cloud"
99
+ }
100
+ }
101
+ ```
102
+
103
+ ### Option 4: Conditional linking (Dev vs Prod)
104
+
105
+ Use environment detection:
106
+
107
+ ```json
108
+ {
109
+ "dependencies": {
110
+ "whatsapp-cloud": process.env.NODE_ENV === "development"
111
+ ? "link:../whatsapp-cloud"
112
+ : "^0.0.5"
113
+ }
114
+ }
115
+ ```
116
+
117
+ **Note:** For CI/CD, you'll need Option 1 (npm publish) or Option 2 (git dependency). `pnpm link` only works locally.
118
+
119
+ ## Type Exports
120
+
121
+ All types are properly exported and can be imported in React/Next.js projects:
122
+
123
+ ```typescript
124
+ // Import client
125
+ import { WhatsAppClient } from "whatsapp-cloud";
126
+
127
+ // Import types
128
+ import type {
129
+ IncomingTextMessage,
130
+ IncomingMessage,
131
+ WebhookPayload,
132
+ MessageContext,
133
+ CreateTemplateRequest,
134
+ // ... all other types
135
+ } from "whatsapp-cloud";
136
+
137
+ // Import schemas (for validation)
138
+ import {
139
+ incomingTextMessageSchema,
140
+ webhookPayloadSchema,
141
+ // ... all other schemas
142
+ } from "whatsapp-cloud";
143
+ ```
144
+
145
+ ## Verifying Exports
146
+
147
+ To verify all types are exported correctly:
148
+
149
+ ```bash
150
+ # In your project
151
+ pnpm exec tsc --noEmit --skipLibCheck
152
+ ```
153
+
154
+ This will check that all imported types are available.
@@ -0,0 +1,104 @@
1
+ # Webhooks
2
+
3
+ Handle incoming WhatsApp messages and status updates via webhooks.
4
+
5
+ ## Quick Start
6
+
7
+ ```typescript
8
+ import { WhatsAppClient } from "@whatsapp-cloud/sdk";
9
+
10
+ const client = new WhatsAppClient({
11
+ accessToken: process.env.WHATSAPP_ACCESS_TOKEN!,
12
+ });
13
+
14
+ // In your webhook endpoint
15
+ app.post("/webhook", async (req, res) => {
16
+ // handle() returns IMMEDIATELY - handlers run in background
17
+ client.webhooks.handle(req.body, {
18
+ text: async (message, context) => {
19
+ // This can take as long as needed (20s, 1min, etc.)
20
+ // Webhook already returned 200, so Meta is happy
21
+
22
+ // Process text message
23
+ console.log(`Received: ${message.text.body} from ${message.from}`);
24
+
25
+ // Store in database
26
+ await db.messages.create({
27
+ id: message.id,
28
+ from: message.from,
29
+ body: message.text.body,
30
+ phoneNumberId: context.metadata.phoneNumberId,
31
+ });
32
+
33
+ // Send response
34
+ await client.messages.sendText({
35
+ to: `+${message.from}`,
36
+ text: { body: "Got it!" },
37
+ });
38
+ },
39
+ });
40
+
41
+ // Returns 200 IMMEDIATELY (handlers continue in background)
42
+ res.json({ success: true });
43
+ });
44
+ ```
45
+
46
+ ## Webhook Verification
47
+
48
+ Meta sends GET requests to verify your webhook endpoint:
49
+
50
+ ```typescript
51
+ app.get("/webhook", (req, res) => {
52
+ const challenge = client.webhooks.verify(req.query, process.env.VERIFY_TOKEN);
53
+ if (challenge) {
54
+ return res.send(challenge);
55
+ }
56
+ return res.status(403).send("Forbidden");
57
+ });
58
+ ```
59
+
60
+ ## Low-Level API
61
+
62
+ For more control, extract messages manually:
63
+
64
+ ```typescript
65
+ const messages = client.webhooks.extractMessages(payload);
66
+ for (const message of messages) {
67
+ // Custom processing
68
+ }
69
+ ```
70
+
71
+ ## Media Downloads
72
+
73
+ Download media files (images, audio, video, documents) from incoming messages:
74
+
75
+ ```typescript
76
+ client.webhooks.handle(req.body, {
77
+ image: async (message, context) => {
78
+ // Download the image
79
+ const imageData = await client.webhooks.downloadMedia(message.image.id);
80
+
81
+ // Upload to your storage (S3, Cloudinary, etc.)
82
+ await s3.upload({
83
+ key: `images/${message.image.id}`,
84
+ body: Buffer.from(imageData),
85
+ contentType: message.image.mime_type || "image/jpeg",
86
+ });
87
+ },
88
+
89
+ audio: async (message, context) => {
90
+ const audioData = await client.webhooks.downloadMedia(message.audio.id);
91
+ // Process audio file...
92
+ },
93
+ });
94
+ ```
95
+
96
+ **Note:** Media files are only available for a limited time. Download them as soon as possible after receiving the webhook.
97
+
98
+ ## API Reference
99
+
100
+ - `client.webhooks.verify(query, token)` - Verify GET request, returns challenge or null
101
+ - `client.webhooks.extractMessages(payload)` - Extract messages from payload
102
+ - `client.webhooks.extractStatuses(payload)` - Extract status updates
103
+ - `client.webhooks.handle(payload, handlers, options?)` - Handle with type-safe callbacks
104
+ - `client.webhooks.downloadMedia(mediaId)` - Download media file by ID, returns ArrayBuffer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whatsapp-cloud",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Work in progress. A WhatsApp client tailored for LLMs—built to actually work.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,7 +11,7 @@ interface APIErrorResponse {
11
11
  * HTTP client for making requests to the WhatsApp Cloud API
12
12
  */
13
13
  export class HttpClient {
14
- private readonly baseURL: string;
14
+ public readonly baseURL: string;
15
15
  public readonly accessToken: string;
16
16
  public readonly phoneNumberId?: string;
17
17
  public readonly businessAccountId?: string;
@@ -95,6 +95,37 @@ export class HttpClient {
95
95
  return response.json() as Promise<T>;
96
96
  }
97
97
 
98
+ /**
99
+ * Make a GET request and return binary data (ArrayBuffer)
100
+ * Useful for downloading media files
101
+ */
102
+ async getBinary(path: string): Promise<ArrayBuffer> {
103
+ const url = `${this.baseURL}/${this.apiVersion}${path}`;
104
+
105
+ const response = await fetch(url, {
106
+ method: "GET",
107
+ headers: {
108
+ Authorization: `Bearer ${this.accessToken}`,
109
+ },
110
+ });
111
+
112
+ if (!response.ok) {
113
+ // Try to parse error response
114
+ let errorMessage = `API Error: ${response.statusText}`;
115
+ try {
116
+ const error = (await response.json()) as APIErrorResponse;
117
+ errorMessage = `API Error: ${
118
+ error.error?.message || response.statusText
119
+ } (${error.error?.code || response.status})`;
120
+ } catch {
121
+ // If JSON parsing fails, use default message
122
+ }
123
+ throw new Error(errorMessage);
124
+ }
125
+
126
+ return response.arrayBuffer();
127
+ }
128
+
98
129
  /**
99
130
  * Make a PATCH request
100
131
  */
@@ -5,6 +5,7 @@ import { MessagesService } from "../services/messages/index";
5
5
  import { AccountsService } from "../services/accounts/index";
6
6
  import { BusinessService } from "../services/business/index";
7
7
  import { TemplatesService } from "../services/templates/index";
8
+ import { WebhooksService } from "../services/webhooks/index";
8
9
  import { ZodError } from "zod";
9
10
  import { transformZodError } from "../utils/zod-error";
10
11
  import type { DebugTokenResponse } from "../types/debug";
@@ -17,6 +18,7 @@ export class WhatsAppClient {
17
18
  public readonly accounts: AccountsService;
18
19
  public readonly business: BusinessService;
19
20
  public readonly templates: TemplatesService;
21
+ public readonly webhooks: WebhooksService;
20
22
 
21
23
  private readonly httpClient: HttpClient;
22
24
 
@@ -40,6 +42,7 @@ export class WhatsAppClient {
40
42
  this.accounts = new AccountsService(this.httpClient);
41
43
  this.business = new BusinessService(this.httpClient);
42
44
  this.templates = new TemplatesService(this.httpClient);
45
+ this.webhooks = new WebhooksService(this.httpClient);
43
46
  }
44
47
 
45
48
  /**
package/src/index.ts CHANGED
@@ -7,6 +7,13 @@ export * from "./schemas/index";
7
7
  // Export types (primary export point)
8
8
  export type * from "./types/index";
9
9
 
10
+ // Export webhook handler types (convenience exports)
11
+ export type {
12
+ MessageContext,
13
+ MessageHandlers,
14
+ HandleOptions,
15
+ } from "./services/webhooks/index";
16
+
10
17
  // Export errors for error handling
11
18
  export {
12
19
  WhatsAppError,
@@ -3,4 +3,5 @@ export * from "./messages/index";
3
3
  export * from "./accounts/index";
4
4
  export * from "./business/index";
5
5
  export * from "./templates/index";
6
+ export * from "./webhooks/index";
6
7
  export * from "./debug";
@@ -5,7 +5,7 @@ import { componentSchema } from "./component";
5
5
  * Schema for creating a template
6
6
  * Simplified - no variables/examples for now
7
7
  */
8
- export const createTemplateRequestSchema = z.object({
8
+ export const templateCreateSchema = z.object({
9
9
  name: z.string().min(1).max(512, "Template name must be 512 characters or less"),
10
10
  language: z.string().min(2).max(5, "Language code must be 2-5 characters (e.g., 'en' or 'en_US')"),
11
11
  category: z.enum(["AUTHENTICATION", "MARKETING", "UTILITY"]),
@@ -49,7 +49,7 @@ export const createTemplateRequestSchema = z.object({
49
49
  * Schema for updating a template
50
50
  * All fields optional - only update what's provided
51
51
  */
52
- export const updateTemplateRequestSchema = z.object({
52
+ export const templateUpdateSchema = z.object({
53
53
  category: z.enum(["AUTHENTICATION", "MARKETING", "UTILITY"]).optional(),
54
54
  components: z.array(componentSchema).optional(),
55
55
  language: z.string().min(2).max(5).optional(),
@@ -2,9 +2,9 @@ import { z } from "zod";
2
2
  import { componentSchema } from "./component";
3
3
 
4
4
  /**
5
- * Schema for template response (single template)
5
+ * Schema for template (the base/select model - what you get from API)
6
6
  */
7
- export const templateResponseSchema = z.object({
7
+ export const templateSchema = z.object({
8
8
  id: z.string(),
9
9
  name: z.string(),
10
10
  language: z.string(),
@@ -0,0 +1,72 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Base fields present in ALL incoming messages
5
+ */
6
+ const baseIncomingMessageSchema = z.object({
7
+ from: z.string(), // WhatsApp ID (phone number without +)
8
+ id: z.string(), // Message ID (wamid.*)
9
+ timestamp: z.string(), // Unix timestamp as string
10
+ type: z.string(), // Message type discriminator
11
+ });
12
+
13
+ /**
14
+ * Text content in incoming text messages
15
+ * Note: Incoming messages don't have preview_url like outgoing
16
+ */
17
+ const incomingTextContentSchema = z.object({
18
+ body: z.string(),
19
+ });
20
+
21
+ /**
22
+ * Audio content in incoming audio messages
23
+ */
24
+ const incomingAudioContentSchema = z.object({
25
+ id: z.string(), // Media ID for downloading
26
+ mime_type: z.string().optional(), // e.g., "audio/ogg; codecs=opus"
27
+ });
28
+
29
+ /**
30
+ * Image content in incoming image messages
31
+ */
32
+ const incomingImageContentSchema = z.object({
33
+ id: z.string(), // Media ID for downloading
34
+ mime_type: z.string().optional(), // e.g., "image/jpeg"
35
+ caption: z.string().optional(), // Optional caption text
36
+ });
37
+
38
+ /**
39
+ * Incoming text message schema
40
+ * Uses discriminated union pattern (type: "text")
41
+ */
42
+ export const incomingTextMessageSchema = baseIncomingMessageSchema.extend({
43
+ type: z.literal("text"),
44
+ text: incomingTextContentSchema,
45
+ });
46
+
47
+ /**
48
+ * Incoming audio message schema
49
+ * Uses discriminated union pattern (type: "audio")
50
+ */
51
+ export const incomingAudioMessageSchema = baseIncomingMessageSchema.extend({
52
+ type: z.literal("audio"),
53
+ audio: incomingAudioContentSchema,
54
+ });
55
+
56
+ /**
57
+ * Incoming image message schema
58
+ * Uses discriminated union pattern (type: "image")
59
+ */
60
+ export const incomingImageMessageSchema = baseIncomingMessageSchema.extend({
61
+ type: z.literal("image"),
62
+ image: incomingImageContentSchema,
63
+ });
64
+
65
+ /**
66
+ * Union of all incoming message types
67
+ */
68
+ export const incomingMessageSchema = z.discriminatedUnion("type", [
69
+ incomingTextMessageSchema,
70
+ incomingAudioMessageSchema,
71
+ incomingImageMessageSchema,
72
+ ]);
@@ -0,0 +1,3 @@
1
+ export * from "./incoming-message";
2
+ export * from "./payload";
3
+
@@ -0,0 +1,56 @@
1
+ import { z } from "zod";
2
+ import { incomingMessageSchema } from "./incoming-message";
3
+
4
+ /**
5
+ * Contact information in webhook
6
+ */
7
+ const contactSchema = z.object({
8
+ profile: z.object({
9
+ name: z.string(),
10
+ }),
11
+ wa_id: z.string(),
12
+ });
13
+
14
+ /**
15
+ * Metadata in webhook value
16
+ */
17
+ const webhookMetadataSchema = z.object({
18
+ display_phone_number: z.string(),
19
+ phone_number_id: z.string(),
20
+ });
21
+
22
+ /**
23
+ * Webhook value (the actual data)
24
+ */
25
+ const webhookValueSchema = z.object({
26
+ messaging_product: z.literal("whatsapp"),
27
+ metadata: webhookMetadataSchema,
28
+ contacts: z.array(contactSchema).optional(),
29
+ messages: z.array(incomingMessageSchema).optional(), // Incoming messages
30
+ statuses: z.array(z.any()).optional(), // Status updates (for later)
31
+ });
32
+
33
+ /**
34
+ * Webhook change entry
35
+ */
36
+ const webhookChangeSchema = z.object({
37
+ value: webhookValueSchema,
38
+ field: z.literal("messages"), // For now: only messages field
39
+ });
40
+
41
+ /**
42
+ * Webhook entry
43
+ */
44
+ const webhookEntrySchema = z.object({
45
+ id: z.string(), // WABA ID
46
+ changes: z.array(webhookChangeSchema),
47
+ });
48
+
49
+ /**
50
+ * Full webhook payload schema
51
+ */
52
+ export const webhookPayloadSchema = z.object({
53
+ object: z.literal("whatsapp_business_account"),
54
+ entry: z.array(webhookEntrySchema),
55
+ });
56
+
@@ -7,15 +7,15 @@ import { updateTemplate } from "./methods/update";
7
7
  import { deleteTemplate } from "./methods/delete";
8
8
  import { WhatsAppValidationError } from "../../errors";
9
9
  import type {
10
- CreateTemplateRequest,
11
- UpdateTemplateRequest,
10
+ TemplateCreate,
11
+ TemplateUpdate,
12
12
  ListTemplatesRequest,
13
13
  DeleteTemplateRequest,
14
14
  } from "../../types/templates/request";
15
15
  import type {
16
16
  CreateTemplateResponse,
17
17
  ListTemplatesResponse,
18
- TemplateResponse,
18
+ Template,
19
19
  UpdateTemplateResponse,
20
20
  DeleteTemplateResponse,
21
21
  } from "../../types/templates/response";
@@ -54,7 +54,7 @@ export class TemplatesService {
54
54
  * @param businessAccountId - Optional WABA ID (overrides client config)
55
55
  */
56
56
  async create(
57
- request: CreateTemplateRequest,
57
+ request: TemplateCreate,
58
58
  businessAccountId?: string
59
59
  ): Promise<CreateTemplateResponse> {
60
60
  const client = this.getClient(businessAccountId);
@@ -82,7 +82,7 @@ export class TemplatesService {
82
82
  *
83
83
  * @param templateId - Template ID
84
84
  */
85
- async get(templateId: string): Promise<TemplateResponse> {
85
+ async get(templateId: string): Promise<Template> {
86
86
  return getTemplate(this.httpClient, templateId);
87
87
  }
88
88
 
@@ -96,7 +96,7 @@ export class TemplatesService {
96
96
  */
97
97
  async update(
98
98
  templateId: string,
99
- request: UpdateTemplateRequest
99
+ request: TemplateUpdate
100
100
  ): Promise<UpdateTemplateResponse> {
101
101
  return updateTemplate(this.httpClient, templateId, request);
102
102
  }
@@ -1,6 +1,6 @@
1
1
  import type { TemplatesClient } from "../TemplatesClient";
2
- import { createTemplateRequestSchema } from "../../../schemas/templates/request";
3
- import type { CreateTemplateRequest } from "../../../types/templates/request";
2
+ import { templateCreateSchema } from "../../../schemas/templates/request";
3
+ import type { TemplateCreate } from "../../../types/templates/request";
4
4
  import type { CreateTemplateResponse } from "../../../types/templates/response";
5
5
  import { transformZodError } from "../../../utils/zod-error";
6
6
 
@@ -12,16 +12,18 @@ import { transformZodError } from "../../../utils/zod-error";
12
12
  */
13
13
  export async function createTemplate(
14
14
  templatesClient: TemplatesClient,
15
- request: CreateTemplateRequest
15
+ request: TemplateCreate
16
16
  ): Promise<CreateTemplateResponse> {
17
17
  // Validate request with schema - throws WhatsAppValidationError if invalid
18
- const result = createTemplateRequestSchema.safeParse(request);
18
+ const result = templateCreateSchema.safeParse(request);
19
19
  if (!result.success) {
20
20
  throw transformZodError(result.error);
21
21
  }
22
22
  const data = result.data;
23
23
 
24
24
  // Make API request - templatesClient handles the WABA ID prefix automatically
25
- return templatesClient.post<CreateTemplateResponse>("/message_templates", data);
25
+ return templatesClient.post<CreateTemplateResponse>(
26
+ "/message_templates",
27
+ data
28
+ );
26
29
  }
27
-
@@ -1,5 +1,5 @@
1
1
  import type { HttpClient } from "../../../client/HttpClient";
2
- import type { TemplateResponse } from "../../../types/templates/response";
2
+ import type { Template } from "../../../types/templates/response";
3
3
 
4
4
  /**
5
5
  * Get a template by ID
@@ -12,12 +12,12 @@ import type { TemplateResponse } from "../../../types/templates/response";
12
12
  export async function getTemplate(
13
13
  httpClient: HttpClient,
14
14
  templateId: string
15
- ): Promise<TemplateResponse> {
15
+ ): Promise<Template> {
16
16
  if (!templateId || templateId.trim().length === 0) {
17
17
  throw new Error("Template ID is required");
18
18
  }
19
19
 
20
20
  // Make API request - template ID is used directly, no WABA prefix
21
- return httpClient.get<TemplateResponse>(`/${templateId}`);
21
+ return httpClient.get<Template>(`/${templateId}`);
22
22
  }
23
23
 
@@ -1,6 +1,6 @@
1
1
  import type { HttpClient } from "../../../client/HttpClient";
2
- import { updateTemplateRequestSchema } from "../../../schemas/templates/request";
3
- import type { UpdateTemplateRequest } from "../../../types/templates/request";
2
+ import { templateUpdateSchema } from "../../../schemas/templates/request";
3
+ import type { TemplateUpdate } from "../../../types/templates/request";
4
4
  import type { UpdateTemplateResponse } from "../../../types/templates/response";
5
5
  import { transformZodError } from "../../../utils/zod-error";
6
6
 
@@ -16,14 +16,14 @@ import { transformZodError } from "../../../utils/zod-error";
16
16
  export async function updateTemplate(
17
17
  httpClient: HttpClient,
18
18
  templateId: string,
19
- request: UpdateTemplateRequest
19
+ request: TemplateUpdate
20
20
  ): Promise<UpdateTemplateResponse> {
21
21
  if (!templateId || templateId.trim().length === 0) {
22
22
  throw new Error("Template ID is required");
23
23
  }
24
24
 
25
25
  // Validate request with schema - throws WhatsAppValidationError if invalid
26
- const result = updateTemplateRequestSchema.safeParse(request);
26
+ const result = templateUpdateSchema.safeParse(request);
27
27
  if (!result.success) {
28
28
  throw transformZodError(result.error);
29
29
  }
@@ -0,0 +1,265 @@
1
+ import type { HttpClient } from "../../client/HttpClient";
2
+ import { extractMessages } from "./utils/extract-messages";
3
+ import { extractStatuses } from "./utils/extract-statuses";
4
+ import { verifyWebhook } from "./utils/verify";
5
+ import { webhookPayloadSchema } from "../../schemas/webhooks/payload";
6
+ import type {
7
+ WebhookPayload,
8
+ IncomingTextMessage,
9
+ IncomingAudioMessage,
10
+ IncomingImageMessage,
11
+ IncomingMessage,
12
+ } from "../../types/webhooks";
13
+
14
+ /**
15
+ * Context provided to message handlers
16
+ * Contains metadata and contact info (message is passed separately)
17
+ */
18
+ export type MessageContext = {
19
+ metadata: {
20
+ phoneNumberId: string;
21
+ displayPhoneNumber: string;
22
+ wabaId: string;
23
+ };
24
+ contact?: {
25
+ name: string;
26
+ waId: string;
27
+ };
28
+ };
29
+
30
+ /**
31
+ * Handler functions for different message types
32
+ * Receives message and context separately - message is the focus, context is optional metadata
33
+ */
34
+ export type MessageHandlers = {
35
+ text?: (
36
+ message: IncomingTextMessage,
37
+ context: MessageContext
38
+ ) => Promise<void> | void;
39
+ audio?: (
40
+ message: IncomingAudioMessage,
41
+ context: MessageContext
42
+ ) => Promise<void> | void;
43
+ image?: (
44
+ message: IncomingImageMessage,
45
+ context: MessageContext
46
+ ) => Promise<void> | void;
47
+ };
48
+
49
+ /**
50
+ * Options for handle() method
51
+ */
52
+ export type HandleOptions = {
53
+ /**
54
+ * Error handler called when a message handler throws an error
55
+ * If not provided, errors are logged and processing continues
56
+ */
57
+ onError?: (error: Error, message: IncomingMessage) => void;
58
+ };
59
+
60
+ /**
61
+ * Webhooks service for handling incoming webhook payloads
62
+ *
63
+ * Provides utilities for extracting messages and a convenience handler
64
+ * for type-safe message processing.
65
+ */
66
+ export class WebhooksService {
67
+ constructor(private readonly httpClient: HttpClient) {}
68
+
69
+ /**
70
+ * Verify webhook GET request from Meta
71
+ *
72
+ * Meta sends GET requests to verify webhook endpoints during setup.
73
+ * Returns the challenge string if valid, null if invalid.
74
+ *
75
+ * @param query - Query parameters from GET request
76
+ * @param verifyToken - Your verification token (stored on your server)
77
+ * @returns Challenge string if valid, null if invalid
78
+ */
79
+ verify(
80
+ query: {
81
+ "hub.mode"?: string;
82
+ "hub.verify_token"?: string;
83
+ "hub.challenge"?: string;
84
+ },
85
+ verifyToken: string
86
+ ): string | null {
87
+ return verifyWebhook(query, verifyToken);
88
+ }
89
+
90
+ /**
91
+ * Extract all incoming messages from webhook payload
92
+ *
93
+ * Low-level utility that flattens the nested webhook structure
94
+ * and returns messages directly.
95
+ *
96
+ * @param payload - Webhook payload from Meta
97
+ * @returns Flat array of incoming messages
98
+ */
99
+ extractMessages(payload: WebhookPayload): IncomingMessage[] {
100
+ return extractMessages(payload);
101
+ }
102
+
103
+ /**
104
+ * Extract status updates from webhook payload
105
+ *
106
+ * Low-level utility for extracting status updates for outgoing messages.
107
+ *
108
+ * @param payload - Webhook payload from Meta
109
+ * @returns Flat array of status updates
110
+ */
111
+ extractStatuses(payload: WebhookPayload): unknown[] {
112
+ return extractStatuses(payload);
113
+ }
114
+
115
+ /**
116
+ * Download media file by media ID
117
+ *
118
+ * Downloads media files (images, audio, video, documents) from WhatsApp servers.
119
+ * Uses the access token from the client configuration automatically.
120
+ *
121
+ * @param mediaId - Media ID from incoming message (e.g., message.image.id, message.audio.id)
122
+ * @returns Promise resolving to ArrayBuffer containing the media file
123
+ * @throws Error if download fails or media ID is invalid
124
+ *
125
+ * @example
126
+ * ```typescript
127
+ * client.webhooks.handle(req.body, {
128
+ * image: async (message, context) => {
129
+ * const mediaData = await client.webhooks.downloadMedia(message.image.id);
130
+ * // Upload to S3, save to disk, etc.
131
+ * await s3.upload({ key: message.image.id, body: Buffer.from(mediaData) });
132
+ * },
133
+ * });
134
+ * ```
135
+ */
136
+ async downloadMedia(mediaId: string): Promise<ArrayBuffer> {
137
+ if (!mediaId || mediaId.trim().length === 0) {
138
+ throw new Error("Media ID is required");
139
+ }
140
+
141
+ // WhatsApp API endpoint: GET /{version}/{media-id}
142
+ // Use HttpClient's getBinary method which handles baseURL, apiVersion, and auth automatically
143
+ return this.httpClient.getBinary(`/${mediaId}`);
144
+ }
145
+
146
+ /**
147
+ * Validate webhook payload structure
148
+ *
149
+ * Validates the payload against the schema. Logs errors if malformed
150
+ * but doesn't throw, allowing processing to continue.
151
+ *
152
+ * @param payload - Raw payload to validate
153
+ * @returns Validated payload if valid, original payload if invalid (with logged error)
154
+ */
155
+ private validatePayload(payload: unknown): WebhookPayload {
156
+ const result = webhookPayloadSchema.safeParse(payload);
157
+ if (!result.success) {
158
+ console.error(
159
+ "Webhook payload validation failed:",
160
+ result.error.format()
161
+ );
162
+ // Return as-is (TypeScript will treat it as WebhookPayload, but it's actually invalid)
163
+ // This allows processing to continue, but handlers should be defensive
164
+ return payload as WebhookPayload;
165
+ }
166
+ return result.data;
167
+ }
168
+
169
+ /**
170
+ * Handle webhook payload with type-safe callbacks
171
+ *
172
+ * High-level convenience method that extracts messages and dispatches
173
+ * them to appropriate handlers based on message type.
174
+ *
175
+ * **Important**: This method returns quickly to allow fast webhook responses.
176
+ * Handlers are processed asynchronously. If you need to await handler completion,
177
+ * use the low-level `extractMessages()` method instead.
178
+ *
179
+ * @param payload - Webhook payload from Meta (will be validated)
180
+ * @param handlers - Object with handler functions for each message type
181
+ * @param options - Optional error handling configuration
182
+ */
183
+ handle(
184
+ payload: unknown,
185
+ handlers: MessageHandlers,
186
+ options?: HandleOptions
187
+ ): void {
188
+ // Validate payload (logs error if malformed, but continues)
189
+ const validatedPayload = this.validatePayload(payload);
190
+
191
+ // Extract metadata and contacts from payload for context
192
+ for (const entry of validatedPayload.entry) {
193
+ for (const change of entry.changes) {
194
+ if (change.field === "messages" && change.value.messages) {
195
+ const metadata = {
196
+ phoneNumberId: change.value.metadata.phone_number_id,
197
+ displayPhoneNumber: change.value.metadata.display_phone_number,
198
+ wabaId: entry.id,
199
+ };
200
+
201
+ const contacts = change.value.contacts || [];
202
+
203
+ // Process each message with its context
204
+ for (const message of change.value.messages) {
205
+ // Find contact for this message (match by wa_id)
206
+ const contact = contacts.find((c) => c.wa_id === message.from);
207
+
208
+ // Build context (metadata + contact, no message duplication)
209
+ const context: MessageContext = {
210
+ metadata,
211
+ ...(contact && {
212
+ contact: {
213
+ name: contact.profile.name,
214
+ waId: contact.wa_id,
215
+ },
216
+ }),
217
+ };
218
+
219
+ // Process handler asynchronously (don't await)
220
+ // This allows long-running handlers without blocking webhook response
221
+ Promise.resolve()
222
+ .then(async () => {
223
+ // Type-safe dispatch based on message type
224
+ switch (message.type) {
225
+ case "text":
226
+ if (handlers.text) {
227
+ await handlers.text(message, context);
228
+ }
229
+ break;
230
+
231
+ case "audio":
232
+ if (handlers.audio) {
233
+ await handlers.audio(message, context);
234
+ }
235
+ break;
236
+
237
+ case "image":
238
+ if (handlers.image) {
239
+ await handlers.image(message, context);
240
+ }
241
+ break;
242
+
243
+ default:
244
+ // Unhandled message type - silently continue
245
+ break;
246
+ }
247
+ })
248
+ .catch((error) => {
249
+ // Handle errors in handler execution
250
+ if (options?.onError) {
251
+ options.onError(error as Error, message);
252
+ } else {
253
+ // Default: log and continue (don't break webhook response)
254
+ console.error(
255
+ `Error handling ${message.type} message ${message.id}:`,
256
+ error
257
+ );
258
+ }
259
+ });
260
+ }
261
+ }
262
+ }
263
+ }
264
+ }
265
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./WebhooksService";
2
+ export type { MessageContext, MessageHandlers, HandleOptions } from "./WebhooksService";
3
+
@@ -0,0 +1,25 @@
1
+ import type { WebhookPayload } from "../../../types/webhooks";
2
+ import type { IncomingMessage } from "../../../types/webhooks/incoming-message";
3
+
4
+ /**
5
+ * Extract all incoming messages from webhook payload
6
+ *
7
+ * Flattens the nested structure: entry[].changes[].value.messages[]
8
+ * Returns a flat array of messages directly
9
+ *
10
+ * @param payload - Webhook payload from Meta
11
+ * @returns Flat array of incoming messages
12
+ */
13
+ export function extractMessages(payload: WebhookPayload): IncomingMessage[] {
14
+ const messages: IncomingMessage[] = [];
15
+
16
+ for (const entry of payload.entry) {
17
+ for (const change of entry.changes) {
18
+ if (change.field === "messages" && change.value.messages) {
19
+ messages.push(...change.value.messages);
20
+ }
21
+ }
22
+ }
23
+
24
+ return messages;
25
+ }
@@ -0,0 +1,25 @@
1
+ import type { WebhookPayload } from "../../../types/webhooks";
2
+
3
+ /**
4
+ * Extract status updates from webhook payload
5
+ *
6
+ * Flattens the nested structure: entry[].changes[].value.statuses[]
7
+ * Returns a flat array of status updates
8
+ *
9
+ * @param payload - Webhook payload from Meta
10
+ * @returns Flat array of status updates
11
+ */
12
+ export function extractStatuses(payload: WebhookPayload): unknown[] {
13
+ const statuses: unknown[] = [];
14
+
15
+ for (const entry of payload.entry) {
16
+ for (const change of entry.changes) {
17
+ if (change.field === "messages" && change.value.statuses) {
18
+ statuses.push(...change.value.statuses);
19
+ }
20
+ }
21
+ }
22
+
23
+ return statuses;
24
+ }
25
+
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Verify webhook GET request from Meta
3
+ *
4
+ * Meta sends GET requests to verify webhook endpoints:
5
+ * GET /webhook?hub.mode=subscribe&hub.challenge=<CHALLENGE>&hub.verify_token=<TOKEN>
6
+ *
7
+ * @param query - Query parameters from GET request
8
+ * @param verifyToken - Your verification token (stored on your server)
9
+ * @returns Challenge string if valid, null if invalid
10
+ */
11
+ export function verifyWebhook(
12
+ query: {
13
+ "hub.mode"?: string;
14
+ "hub.verify_token"?: string;
15
+ "hub.challenge"?: string;
16
+ },
17
+ verifyToken: string
18
+ ): string | null {
19
+ const mode = query["hub.mode"];
20
+ const token = query["hub.verify_token"];
21
+ const challenge = query["hub.challenge"];
22
+
23
+ // Verify mode is "subscribe" and token matches
24
+ if (mode === "subscribe" && token === verifyToken && challenge) {
25
+ return challenge;
26
+ }
27
+
28
+ return null;
29
+ }
@@ -3,4 +3,5 @@ export type * from "./messages/index";
3
3
  export type * from "./accounts/index";
4
4
  export type * from "./business/index";
5
5
  export type * from "./templates/index";
6
+ export type * from "./webhooks/index";
6
7
  export type * from "./debug";
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import {
3
- createTemplateRequestSchema,
4
- updateTemplateRequestSchema,
3
+ templateCreateSchema,
4
+ templateUpdateSchema,
5
5
  listTemplatesRequestSchema,
6
6
  deleteTemplateRequestSchema,
7
7
  } from "../../schemas/templates/request";
@@ -9,12 +9,12 @@ import {
9
9
  /**
10
10
  * Type for creating a template
11
11
  */
12
- export type CreateTemplateRequest = z.infer<typeof createTemplateRequestSchema>;
12
+ export type TemplateCreate = z.infer<typeof templateCreateSchema>;
13
13
 
14
14
  /**
15
15
  * Type for updating a template
16
16
  */
17
- export type UpdateTemplateRequest = z.infer<typeof updateTemplateRequestSchema>;
17
+ export type TemplateUpdate = z.infer<typeof templateUpdateSchema>;
18
18
 
19
19
  /**
20
20
  * Type for listing templates
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import {
3
- templateResponseSchema,
3
+ templateSchema,
4
4
  createTemplateResponseSchema,
5
5
  listTemplatesResponseSchema,
6
6
  updateTemplateResponseSchema,
@@ -8,9 +8,9 @@ import {
8
8
  } from "../../schemas/templates/response";
9
9
 
10
10
  /**
11
- * Type for a single template
11
+ * Type for a template (the base/select model - what you get from API)
12
12
  */
13
- export type TemplateResponse = z.infer<typeof templateResponseSchema>;
13
+ export type Template = z.infer<typeof templateSchema>;
14
14
 
15
15
  /**
16
16
  * Type for create template response
@@ -0,0 +1,27 @@
1
+ import { z } from "zod";
2
+ import {
3
+ incomingTextMessageSchema,
4
+ incomingAudioMessageSchema,
5
+ incomingImageMessageSchema,
6
+ incomingMessageSchema,
7
+ } from "../../schemas/webhooks/incoming-message";
8
+
9
+ /**
10
+ * Type for incoming text message
11
+ */
12
+ export type IncomingTextMessage = z.infer<typeof incomingTextMessageSchema>;
13
+
14
+ /**
15
+ * Type for incoming audio message
16
+ */
17
+ export type IncomingAudioMessage = z.infer<typeof incomingAudioMessageSchema>;
18
+
19
+ /**
20
+ * Type for incoming image message
21
+ */
22
+ export type IncomingImageMessage = z.infer<typeof incomingImageMessageSchema>;
23
+
24
+ /**
25
+ * Union type for all incoming message types
26
+ */
27
+ export type IncomingMessage = z.infer<typeof incomingMessageSchema>;
@@ -0,0 +1,3 @@
1
+ export * from "./incoming-message";
2
+ export * from "./payload";
3
+
@@ -0,0 +1,8 @@
1
+ import { z } from "zod";
2
+ import { webhookPayloadSchema } from "../../schemas/webhooks/payload";
3
+
4
+ /**
5
+ * Type for webhook payload
6
+ */
7
+ export type WebhookPayload = z.infer<typeof webhookPayloadSchema>;
8
+