Developer Guide: Integrating RedwoodJS with WhatsApp using AWS SNS
This guide provides a step-by-step walkthrough for building a production-ready integration between a RedwoodJS application and WhatsApp, leveraging AWS Simple Notification Service (SNS) and AWS End User Messaging Social for seamless message handling.
Project Overview and Goals
This guide details how to build a system where your RedwoodJS application can receive incoming WhatsApp messages sent to your business number and send replies back to users.
Problem Solved: Businesses need effective ways to communicate with customers on preferred platforms like WhatsApp. This integration enables automated responses, custom workflows, and data logging triggered by customer messages directly within your RedwoodJS application.
Technologies Used:
- RedwoodJS: A full-stack JavaScript/TypeScript framework for the web. We'll use its API side (Node.js/Fastify) to handle incoming messages and business logic.
- WhatsApp Business Account (WABA): Required to represent your business on WhatsApp.
- AWS End User Messaging Social: The AWS service that links your WABA to your AWS account, simplifying integration. (Note: AWS documentation might also refer to related functionality under services like Amazon Pinpoint; verify current naming conventions if needed.)
- AWS Simple Notification Service (SNS): A pub/sub messaging service. AWS End User Messaging Social will publish incoming WhatsApp messages to an SNS topic.
- AWS SDK for JavaScript v3: Used within the RedwoodJS API function to interact with AWS services, particularly for sending replies.
- Node.js: The runtime environment for the RedwoodJS API side.
- Prisma: RedwoodJS's default ORM for database interactions (optional but recommended for storing data).
System Architecture:
graph LR
subgraph Incoming Flow
WA_User[WhatsApp User (Sends Message)] --> Meta_WABA[WhatsApp Business Account (via Meta Platform)];
Meta_WABA --> AWS_EUMS[AWS End User Messaging Social];
AWS_EUMS --> AWS_SNS[AWS SNS Topic (WhatsAppIncomingMsg)];
AWS_SNS --> Redwood_API_In[RedwoodJS API Function (Webhook Handler)];
end
subgraph Processing and Reply
Redwood_API_In --> Logic{Process Message<br/>(Optional: DB Save via Prisma)};
Logic --> AWS_SDK[AWS SDK for JS (Send Reply)];
end
subgraph Outgoing Flow
AWS_SDK --> AWS_SocialAPI[AWS Social Messaging API];
AWS_SocialAPI --> Meta_WABA_Out[WhatsApp Business Account (via Meta Platform)];
Meta_WABA_Out --> WA_User_Reply[WhatsApp User (Receives Reply)];
end
Redwood_API_In -- Triggers --> Logic;
Logic -- Calls --> AWS_SDK;
AWS_SDK -- Sends via --> AWS_SocialAPI;
Expected Outcome: A RedwoodJS application capable of:
- Receiving webhook notifications from AWS SNS containing incoming WhatsApp messages.
- Parsing these messages within a RedwoodJS API function.
- (Optional) Storing message details or related data in a database using Prisma.
- Sending replies back to the originating WhatsApp user via the AWS SDK.
Prerequisites:
- An AWS account with appropriate IAM permissions to create/manage SNS, End User Messaging Social, and potentially IAM roles/Lambda (if deploying the function that way).
- A Meta Business Account and a phone number eligible for creating a WABA. Follow Meta's instructions if you don't have one.
- A separate phone with the WhatsApp Messenger app installed for testing (cannot be the same number as the WABA).
- Node.js >= 18.x installed locally.
- Yarn package manager installed (
npm install -g yarn
). - AWS CLI installed and configured (
aws configure
). - Basic familiarity with RedwoodJS concepts (API functions, environment variables).
1. Setting up the RedwoodJS Project
First, create a new RedwoodJS project if you don't have one already.
-
Create RedwoodJS App: Open your terminal and run:
yarn create redwood-app ./redwood-whatsapp-sns cd redwood-whatsapp-sns
Choose your preferred options (TypeScript recommended).
-
Environment Variables: RedwoodJS uses a
.env
file for environment variables. Create one in the project root:touch .env
Add the following placeholders. We will populate these later.
# .env # AWS Credentials (Ensure secure handling in production - e.g., IAM Roles) AWS_ACCESS_KEY_ID=YOUR_AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_ACCESS_KEY AWS_REGION=us-east-1 # Or your preferred AWS region # AWS SNS & WABA Details (Obtained in later steps) INCOMING_SNS_TOPIC_ARN=YOUR_SNS_TOPIC_ARN WABA_PHONE_NUMBER_ID=YOUR_WABA_PHONE_NUMBER_ID # Optional: Security token for SNS subscription confirmation # SNS_SUBSCRIBE_TOKEN=YOUR_SECRET_TOKEN
Important: Add
.env
to your.gitignore
file to avoid committing secrets. For production deployments, use environment variables provided by your hosting platform or secrets management tools instead of hardcoding AWS keys. IAM roles are the recommended approach for AWS deployments. -
Install AWS SDK: Install the necessary AWS SDK v3 clients for SNS and Social Messaging. Note: The package name
@aws-sdk/client-social-post-messaging
is used here based on available information. Verify this is the correct and current package name in the official AWS SDK v3 documentation, as it might be part of Pinpoint, Chime SDK Messaging, or another service.yarn workspace api add @aws-sdk/client-sns @aws-sdk/client-social-post-messaging
Install the library for SNS signature verification:
yarn workspace api add aws-sns-verify
Install Zod for validation:
yarn workspace api add zod
Install cuid if saving outgoing messages with temporary IDs (Section 6):
yarn workspace api add cuid
2. Implementing Core Functionality: The API Function
We'll create a RedwoodJS API function to act as the webhook endpoint for SNS notifications.
-
Generate API Function: Use the RedwoodJS CLI to generate a new function:
yarn rw g function whatsappWebhook --ts
This creates
api/src/functions/whatsappWebhook.ts
and a test file. -
Implement Function Logic (Initial Version - Receiving & Parsing): Replace the contents of
api/src/functions/whatsappWebhook.ts
with the following code. This version includes SNS verification and parsing, preparing for reply logic.// api/src/functions/whatsappWebhook.ts import type { APIGatewayEvent, Context } from 'aws-lambda' import { logger } from 'src/lib/logger' import { Verifier } from 'aws-sns-verify' // For SNS Signature Verification import { SNSClient, ConfirmSubscriptionCommand } from '@aws-sdk/client-sns' import { z } from 'zod' // Import Prisma client if you plan to use the database // import { db } from 'src/lib/db' // Import the reply function (to be implemented in Section 4) // import { sendWhatsAppReply } from './whatsappWebhook.lib' // Example: move reply logic to separate file // Initialize AWS Clients (outside handler for potential reuse) const awsRegion = process.env.AWS_REGION || 'us-east-1' const snsClient = new SNSClient({ region: awsRegion }) // Initialize SNS Verifier (consider caching certificates in production) const verifier = new Verifier() // Zod Schema for WhatsApp Payload (Example - Verify against actual payload) // See Section 3 for details and notes on verification const WhatsappMessageSchema = z.object({ object: z.string(), entry: z.array(z.object({ changes: z.array(z.object({ value: z.object({ messaging_product: z.literal('whatsapp'), metadata: z.object({ display_phone_number: z.string(), phone_number_id: z.string(), }), contacts: z.array(z.object({ profile: z.object({ name: z.string() }), wa_id: z.string() })).optional(), messages: z.array(z.object({ from: z.string(), id: z.string(), timestamp: z.string(), type: z.string(), // 'text', 'image', etc. text: z.object({ body: z.string() }).optional(), // Add other message types (image, audio, document, location) as needed })), }), field: z.literal('messages'), })), })), }); /** * The handler function is executed for every HTTP request that reaches the endpoint. * RedwoodJS automatically routes requests to `/api/whatsappWebhook` to this function. * When subscribed to SNS, AWS sends POST requests with SNS notification payloads. */ export const handler = async (event: APIGatewayEvent, _context: Context) => { logger.info('Received request on whatsappWebhook function') // --- 1. Validate SNS Signature (Mandatory for Production) --- let snsMessage: any try { // aws-sns-verify expects the raw payload parsed as JSON const rawPayload = JSON.parse(event.body || '{}') await verifier.verify(rawPayload) // Throws error if invalid snsMessage = rawPayload // Use the verified payload logger.info({ type: snsMessage.Type }, 'SNS Signature Verified Successfully') } catch (error) { logger.error({ error, body: event.body }, 'SNS Signature Verification Failed') return { statusCode: 403, body: 'Forbidden: Invalid SNS Signature' } } // --- 2. Handle SNS Subscription Confirmation --- if (snsMessage.Type === 'SubscriptionConfirmation') { // **VERY IMPORTANT**: Validate snsMessage.TopicArn === process.env.INCOMING_SNS_TOPIC_ARN // before confirming automatically to prevent misuse. if (snsMessage.TopicArn !== process.env.INCOMING_SNS_TOPIC_ARN) { logger.error('Attempted subscription confirmation for wrong topic ARN:', snsMessage.TopicArn); return { statusCode: 400, body: 'Bad Request: Invalid Topic ARN' }; } logger.info(`Attempting to confirm SNS subscription programmatically for token: ${snsMessage.Token}`); try { const command = new ConfirmSubscriptionCommand({ TopicArn: snsMessage.TopicArn, // Use ARN from message after validation Token: snsMessage.Token, }); await snsClient.send(command); logger.info('SNS Subscription Confirmed programmatically'); return { statusCode: 200, body: 'Subscription Confirmed' }; } catch (confirmError) { logger.error({ confirmError }, 'Failed to confirm SNS subscription'); // Let SNS retry if confirmation fails temporarily return { statusCode: 500, body: 'Subscription confirmation failed' }; } } // --- 3. Handle Actual Notification (Incoming WhatsApp Message) --- if (snsMessage.Type === 'Notification') { try { // Parse the inner message which contains the WhatsApp payload const rawWhatsappPayload = JSON.parse(snsMessage.Message) // Validate the WhatsApp payload structure const validationResult = WhatsappMessageSchema.safeParse(rawWhatsappPayload); if (!validationResult.success) { logger.error({ errors: validationResult.error.issues, payload: rawWhatsappPayload }, 'WhatsApp payload validation failed'); // Return 400 Bad Request as retrying won't fix a structural issue return { statusCode: 400, body: 'Bad Request: Invalid WhatsApp payload structure' }; } const whatsappPayload = validationResult.data; // Use validated data logger.info( { messageId: whatsappPayload?.entry?.[0]?.changes?.[0]?.value?.messages?.[0]?.id }, 'Processing validated incoming WhatsApp message notification' ) // Extract relevant information const messageEntry = whatsappPayload.entry[0]?.changes[0]?.value; const messageDetails = messageEntry?.messages?.[0]; const senderWaId = messageDetails?.from; // WhatsApp ID (phone number) const messageText = messageDetails?.text?.body; const senderProfileName = messageEntry?.contacts?.[0]?.profile?.name; const messageTimestamp = messageDetails?.timestamp; const messageId = messageDetails?.id; // WhatsApp message ID if (!senderWaId || !messageId) { logger.warn({ payload: whatsappPayload }, 'Essential message details missing from payload') // Return 400 Bad Request return { statusCode: 400, body: 'Bad Request: Incomplete message data' } } logger.info( `Message ID ${messageId} from ${senderProfileName || 'Unknown'} (${senderWaId}): ""${messageText || '[Non-text message]'}""` ) // --- TODO: Add your business logic here --- // - Save message to DB using Prisma (See Section 6) // - Determine response based on messageText // - Trigger other workflows // --- TODO: Call function to send reply (Implement in Section 4) --- // Example call: // const replyText = `Received: ""${messageText}""`; // await sendWhatsAppReply(senderWaId, replyText); // logger.info(`Reply attempt initiated for message ID ${messageId}`); // Remember the 24-hour rule for free-form replies (See Section 4 & 8) // Acknowledge receipt to SNS return { statusCode: 200, body: 'Notification received successfully' } } catch (error) { // Catch errors during inner message parsing or business logic (but not reply sending yet) logger.error({ error, snsMessage }, 'Error processing SNS Notification message') // Return 500 to allow SNS retries for potentially transient processing issues // If the error is definitely permanent (e.g., validation already handled), consider 400. return { statusCode: 500, body: 'Internal Server Error processing notification' } } } // --- 4. Handle other SNS message types if necessary --- logger.warn({ snsMessageType: snsMessage.Type }, 'Received unhandled SNS message type') return { statusCode: 200, body: 'Message received but not processed' } // Acknowledge other types }
Explanation:
- The
handler
receives the HTTP request from SNS. - SNS Signature Verification: Uses
aws-sns-verify
to ensure the request is authentic before processing. This is crucial for security. - SubscriptionConfirmation: Handles the initial handshake with SNS programmatically, validating the Topic ARN first.
- Notification: This is the main path.
- It parses the inner
Message
field (containing the WhatsApp payload). - It validates the structure of this payload using the
WhatsappMessageSchema
(Zod). - It extracts key details (sender ID, text, etc.). Note: The exact payload structure can change; always refer to the latest Meta documentation.
- TODO: Integrate your business logic and prepare for sending replies (Section 4).
- It parses the inner
- Returns
200 OK
for successful processing or appropriate error codes (403
for bad signature,400
for invalid payload,500
for processing errors or failed subscription confirmation to allow retries).
- The
3. Building the API Layer
In RedwoodJS, the API function whatsappWebhook
is the API layer for this integration point.
-
Endpoint: RedwoodJS exposes this function at
/api/whatsappWebhook
. This is the URL you provide to AWS SNS. -
Authentication/Authorization:
- From SNS: The primary security is SNS Signature Verification (implemented in Section 2). This cryptographically verifies the request origin.
- Internal Calls: Standard authentication (API keys, JWTs) applies if this function calls other internal/external APIs.
-
Request Validation:
- SNS Signature verification handles authenticity.
- The Zod schema (
WhatsappMessageSchema
) validates the structure of the inner WhatsApp payload (snsMessage.Message
). Important: This schema is an example based on common structures. You must verify and potentially adjust it based on the actual payloads you receive from Meta/AWS via SNS, as these can evolve. Test with real messages.
-
API Documentation (SNS Input): The function expects POST requests with a JSON body matching the AWS SNS notification format. The critical field is
Message
, which contains a stringified JSON payload from AWS End User Messaging Social, mirroring Meta's Webhook format.- SNS Wrapper Example (Input to the function):
{ "Type": "Notification", "MessageId": "abcd-1234-...", "TopicArn": "arn:aws:sns:us-east-1:111122223333:YourTopicName", "Subject": null, "Message": "{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"WABA_ID\",\"changes\":[{\"value\":{\"messaging_product\":\"whatsapp\",\"metadata\":{\"display_phone_number\":\"+1555...\",\"phone_number_id\":\"YOUR_WABA_PHONE_NUMBER_ID\"},\"contacts\":[{\"profile\":{\"name\":\"Test User\"},\"wa_id\":\"16505551234\"}],\"messages\":[{\"from\":\"16505551234\",\"id\":\"wamid.HB...\",\"timestamp\":\"1679...\",\"text\":{\"body\":\"Hello Redwood\"},\"type\":\"text\"}]},\"field\":\"messages\"}]}]}", "Timestamp": "2024-04-20T10:30:00.000Z", "SignatureVersion": "1", "Signature": "...", "SigningCertURL": "...", "UnsubscribeURL": "..." }
- Inner
Message
Payload (Parsed WhatsApp Event - for clarity): This is what theJSON.parse(snsMessage.Message)
call produces (structure needs verification):Refer to the official Meta for Developers documentation for the most current payload structure.{ "object": "whatsapp_business_account", "entry": [ { "id": "WABA_ID", "changes": [ { "value": { "messaging_product": "whatsapp", "metadata": { "display_phone_number": "+1555...", "phone_number_id": "YOUR_WABA_PHONE_NUMBER_ID" }, "contacts": [ { "profile": { "name": "Test User" }, "wa_id": "16505551234" } ], "messages": [ { "from": "16505551234", "id": "wamid.HB...", "timestamp": "1679...", "text": { "body": "Hello Redwood" }, "type": "text" } ] }, "field": "messages" } ] } ] }
- SNS Wrapper Example (Input to the function):
-
Testing (Simulating SNS): Use
curl
or Postman to send a POST request to your local development server's webhook endpoint (http://localhost:8911/api/whatsappWebhook
by default). You'll need to generate a valid signature or temporarily disable verification for local testing. Tools exist to help craft signed SNS messages if needed. Basiccurl
without a valid signature will be rejected if verification is active.
4. Integrating with AWS Services (SNS, End User Messaging, Sending Replies)
This section covers setting up AWS infrastructure and enabling replies.
-
Create SNS Topic (AWS Console or CLI):
- Console: Navigate to SNS -> Topics -> Create topic. Type:
Standard
, Name:WhatsAppIncomingMessages
(or similar). Note the Topic ARN. Add it to your.env
(INCOMING_SNS_TOPIC_ARN
). - CLI:
aws sns create-topic --name WhatsAppIncomingMessages --region YOUR_REGION
(Copy theTopicArn
).
- Console: Navigate to SNS -> Topics -> Create topic. Type:
-
Link WABA to AWS End User Messaging Social:
- Navigate to the WABA page in the AWS Console (search for ""WhatsApp Business Accounts"" or similar under End User Computing / Messaging).
- Click ""Add WhatsApp phone number"" (or similar wording).
- Click ""Launch connection wizard"" (or similar, e.g., ""Launch Facebook portal"").
- Follow the Meta pop-up instructions carefully: Log in, select the correct Meta Business Account, WhatsApp Business Account (WABA), and the specific phone number profile you want to link. Grant necessary permissions.
- After the pop-up closes and you return to AWS, confirm the link is established.
- In the ""Message and event destination"" (or similar) section, select SNS as the destination type and paste the ARN of the SNS topic you created (
INCOMING_SNS_TOPIC_ARN
). - Save or ""Add phone number"".
- Verify the account status becomes ""Active"" on the WABA page in AWS.
- Click the Business account ID/Name you added. Under ""Phone Numbers"" (or similar tab), find the number you linked and copy its Phone number ID. Add this to your
.env
file (WABA_PHONE_NUMBER_ID
).
-
Subscribe RedwoodJS Function to SNS Topic:
- Deploy First: Your RedwoodJS API function needs to be deployed to a publicly accessible HTTPS URL (See Section 12 - Note: Section 12 is not present in the provided text, assume deployment is covered elsewhere). Assume URL is
https://your-app.com/api/whatsappWebhook
. - Subscribe (AWS Console or CLI):
- Console: Go to your SNS Topic -> Subscriptions -> Create subscription. Protocol:
HTTPS
, Endpoint:https://your-app.com/api/whatsappWebhook
, Disable ""Enable raw message delivery"". Create subscription. - CLI:
aws sns subscribe --topic-arn YOUR_SNS_TOPIC_ARN --protocol https --endpoint https://your-app.com/api/whatsappWebhook --region YOUR_REGION
- Console: Go to your SNS Topic -> Subscriptions -> Create subscription. Protocol:
- Confirm Subscription: SNS sends a
SubscriptionConfirmation
message. Your deployed function's logic (from Section 2) should handle this programmatically (validating the ARN and callingConfirmSubscriptionCommand
). Monitor logs to ensure confirmation succeeds. The subscription status in AWS changes from ""Pending confirmation"" to ""Confirmed"".
- Deploy First: Your RedwoodJS API function needs to be deployed to a publicly accessible HTTPS URL (See Section 12 - Note: Section 12 is not present in the provided text, assume deployment is covered elsewhere). Assume URL is
-
Implement Sending Replies: Update the
whatsappWebhook.ts
function (or create a helper file likewhatsappWebhook.lib.ts
) to include the reply logic.// api/src/functions/whatsappWebhook.ts OR whatsappWebhook.lib.ts import { logger } from 'src/lib/logger' import { SocialPostMessagingClient, // Verify this is the correct client name from the SDK package SendWhatsappMessageCommand, SendWhatsappMessageCommandInput, } from '@aws-sdk/client-social-post-messaging' // Verify this package name (@aws-sdk/...) import { z } from 'zod' // If used for error handling here // Initialize AWS SDK Client for sending messages const awsRegion = process.env.AWS_REGION || 'us-east-1' // Verify the correct client to use based on the package name verification const socialMessagingClient = new SocialPostMessagingClient({ region: awsRegion }) /** * Sends a simple text reply via AWS Social Messaging API. * * VERY IMPORTANT: WhatsApp enforces a 24-hour window for free-form replies * after the last incoming message from the user. This function, sending * free-form text, will ONLY work within that window. Outside the 24-hour * window, you MUST use pre-approved WhatsApp Message Templates. Sending * templates requires a different structure in the SendWhatsappMessageCommandInput. * * @param recipientWaId - The user's WhatsApp ID (e.g., ""16505551234"") * @param messageBody - The text content of the reply message */ export async function sendWhatsAppReply(recipientWaId: string, messageBody: string): Promise<void> { const wabaPhoneNumberId = process.env.WABA_PHONE_NUMBER_ID if (!wabaPhoneNumberId) { logger.error('WABA_PHONE_NUMBER_ID environment variable is not set.') // Throw error to be caught by the main handler throw new Error('Missing WABA Phone Number ID configuration.') } // --- CRITICAL: VERIFY PARAMS STRUCTURE --- // The structure of `SendWhatsappMessageCommandInput` (`params`) below is a // PLAUSIBLE EXAMPLE based on common AWS SDK patterns and CLI examples. // You MUST verify the exact required fields and structure against the // official AWS SDK v3 documentation for the specific SDK client/package // you are using (e.g., @aws-sdk/client-social-post-messaging or alternatives // like Pinpoint/Chime SDK if applicable). // The correct structure is ESSENTIAL for this function to work. const params: SendWhatsappMessageCommandInput = { // Option 1: Guess based on potential SDK structure OriginationIdentity: wabaPhoneNumberId, // Or OriginationPhoneNumberId? DestinationIdentity: recipientWaId, // Or ToAddress? Content: { Text: messageBody, // For Template Messages, structure would differ, e.g.: // Template: { Name: 'template_name', LanguageCode: 'en_US', Parameters: [...] } }, // Configuration: { WhatsApp: { /* ... maybe needed ... */ } }, // Option 2: Closer to CLI structure (less likely for SDK v3 but possible) // Message: JSON.stringify({ // messaging_product: 'whatsapp', // to: recipientWaId, // type: 'text', // text: { preview_url: false, body: messageBody }, // }), // OriginationPhoneNumberId: wabaPhoneNumberId, } // --- END CRITICAL VERIFICATION NOTE --- try { logger.info({ recipientWaId, wabaPhoneNumberId, bodyLength: messageBody.length }, 'Attempting to send WhatsApp reply via AWS SDK') // Ensure 'params' matches the verified structure from AWS SDK docs const command = new SendWhatsappMessageCommand(params) const result = await socialMessagingClient.send(command) // Log the message ID returned by AWS if available (field name might vary) logger.info({ awsMessageId: result.MessageId /* or similar field like result.messageMetadata?.messageId */ }, 'Successfully sent WhatsApp reply via AWS SDK') } catch (error) { logger.error({ error, recipientWaId, wabaPhoneNumberId }, 'Failed to send WhatsApp reply via AWS SDK') // Re-throw the error to be caught by the main handler, allowing SNS retries // if the error seems transient (e.g., network issue, throttling). throw error // Let the caller handle retry logic/response codes } } // --- In whatsappWebhook.ts (handler function) --- // Make sure to import and call sendWhatsAppReply: /* import { sendWhatsAppReply } from './whatsappWebhook.lib' // Or wherever defined // ... inside the 'Notification' block after processing ... try { // ... existing logic ... // --- Business Logic & Reply --- let replyText = `Hi ${senderProfileName || 'there'}, we received: ""${messageText}"".`; // Add logic to determine replyText... // Call the reply function // IMPORTANT: This free-form reply only works within 24hrs of user's message! await sendWhatsAppReply(senderWaId, replyText); logger.info(`Reply sent for message ID ${messageId}`); // Optional: Save outgoing message to DB (See Section 6) return { statusCode: 200, body: 'Notification received and reply sent' }; } catch (error) { logger.error({ error }, 'Failed processing notification or sending reply'); if (error instanceof z.ZodError) { // Example specific error handling return { statusCode: 400, body: 'Bad Request: Invalid payload' }; } // Let SNS retry for potentially transient errors (like SDK call failures) return { statusCode: 500, body: 'Internal Server Error during processing or reply' }; } */
Key Changes & Considerations:
- Imported the necessary SDK client and command (verify names!).
- Initialized the SDK client.
- Implemented
sendWhatsAppReply
using the client. - CRITICAL: Added strong warnings that the
SendWhatsappMessageCommandInput
structure (params
) must be verified against official AWS SDK documentation. The provided code is a placeholder guess. - Emphasized the 24-hour rule for free-form replies within the function documentation and near the call site.
- Included error handling for the send operation, re-throwing errors to allow the main handler to manage the response to SNS (e.g., return 500 for retries on transient SDK errors).
- Integrated the call to
sendWhatsAppReply
into the main handler'sNotification
block (shown commented out for integration).
5. Implementing Error Handling, Logging, and Retry Mechanisms
- Error Handling:
- Use
try...catch
blocks extensively. - SNS Signature Failures: Return
403 Forbidden
. No retry needed. - Payload Validation Errors (Zod): Return
400 Bad Request
. The message structure is wrong; retrying won't help. - Missing Configuration (e.g., Env Vars): Log error, return
500 Internal Server Error
. Fix config and potentially use DLQ for failed messages. - SDK Call Failures (
sendWhatsAppReply
): Catch errors. Log details. Return500 Internal Server Error
to signal to SNS that it should retry (could be throttling, temporary network issue). - Database Errors: Catch errors. Log details. Return
500 Internal Server Error
for potential retries.
- Use
- Logging:
- Use RedwoodJS
logger
(import { logger } from 'src/lib/logger'
). - Log key events: function start, signature verification result, message type, parsed details (sanitize PII!), reply attempts, success/failure of operations, errors with context.
- Use structured logging:
logger.info({ key: value }, 'Message')
. - Configure
LOG_LEVEL
per environment. - PII: Avoid logging full message bodies or user identifiers unless necessary and compliant with privacy policies.
- Use RedwoodJS
- Retry Mechanisms:
- SNS Retries: SNS automatically retries HTTPS endpoints on
5xx
errors or timeouts. Customize the retry policy (attempts, backoff) in the SNS subscription settings if needed. - Dead-Letter Queue (DLQ): Highly recommended. Create an SQS queue to act as a DLQ for the SNS subscription. If SNS fails delivery after all retries, the message goes to the DLQ for inspection and potential manual reprocessing. Configure this in the SNS subscription settings. Requires granting SNS permission to write to the SQS queue.
- SNS Retries: SNS automatically retries HTTPS endpoints on
- Testing Error Scenarios: Test invalid signatures, malformed JSON, SDK failures (by mocking rejection), DB errors (mocking rejection), and timeouts (
setTimeout
) to ensure correct error code responses and DLQ behavior.
6. Creating a Database Schema and Data Layer (Optional)
Use Prisma to store message history or related data.
-
Define Schema: Edit
api/db/schema.prisma
.// api/db/schema.prisma datasource db { provider = ""postgresql"" // Or your chosen database url = env(""DATABASE_URL"") } generator client { provider = ""prisma-client-js"" } model WhatsappMessage { id String @id @default(cuid()) // Internal DB ID whatsappId String @unique // wamid from Meta senderWaId String // User's WA ID recipientWaId String // Your WABA number ID body String? // Message text content type String // Message type ('text', 'image', etc.) direction String // 'incoming' or 'outgoing' timestamp DateTime // When the message was sent/received (from Meta timestamp) status String? // Optional: 'sent', 'delivered', 'read' (for outgoing) createdAt DateTime @default(now()) // DB record creation time updatedAt DateTime @updatedAt // Add relations if needed, e.g., to a User or Conversation model // conversationId String? // conversation Conversation? @relation(fields: [conversationId], references: [id]) } // Example Conversation model // model Conversation { // id String @id @default(cuid()) // userWaId String @unique // Link conversation to a specific user WA ID // messages WhatsappMessage[] // createdAt DateTime @default(now()) // updatedAt DateTime @updatedAt // }
-
Apply Schema Changes: Run Prisma Migrate to update your database schema and generate the Prisma Client.
yarn rw prisma migrate dev --name add-whatsapp-message-model
-
Use Prisma Client in API Function: Modify
api/src/functions/whatsappWebhook.ts
to save incoming messages.// api/src/functions/whatsappWebhook.ts // ... other imports ... import { db } from 'src/lib/db' // Import Prisma client import cuid from 'cuid' // If needed for outgoing message temp ID // ... inside the 'Notification' block ... if (snsMessage.Type === 'Notification') { try { // ... existing parsing and validation ... if (!senderWaId || !messageId) { // ... handle missing data ... return { statusCode: 400, body: 'Bad Request: Incomplete message data' } } logger.info( `Message ID ${messageId} from ${senderProfileName || 'Unknown'} (${senderWaId}): ""${messageText || '[Non-text message]'}""` ) // --- Save Incoming Message to DB --- try { await db.whatsappMessage.create({ data: { whatsappId: messageId, // Use Meta's message ID as unique identifier senderWaId: senderWaId, recipientWaId: messageEntry.metadata.phone_number_id, // Your WABA ID from payload body: messageText, // Null if not a text message type: messageDetails.type, direction: 'incoming', timestamp: new Date(parseInt(messageTimestamp) * 1000), // Convert Unix timestamp string to Date // status: null, // Status usually applies to outgoing }, }) logger.info({ whatsappId: messageId }, 'Incoming message saved to database') } catch (dbError) { // Log error, but decide if it should prevent acknowledging SNS // If DB save is critical, return 500 to retry. If not, maybe just log and continue. logger.error({ error: dbError, whatsappId: messageId }, 'Failed to save incoming message to database') // Example: Allow processing to continue but log failure // return { statusCode: 500, body: 'Internal Server Error saving message' }; // Uncomment to force retry on DB error } // --- Business Logic & Reply --- let replyText = `Hi ${senderProfileName || 'there'}, we received: ""${messageText}"".`; // Add logic to determine replyText... // --- Send Reply (Call function from Section 4) --- try { // IMPORTANT: Free-form reply only works within 24hrs! await sendWhatsAppReply(senderWaId, replyText); logger.info(`Reply sent for message ID ${messageId}`); // --- Optional: Save Outgoing Message Placeholder --- // You might want to save a record for the outgoing message. // AWS SDK response might contain an ID, but it's async. // You could save a placeholder and update status later via separate webhooks if needed. // const outgoingTempId = cuid(); // Generate a temporary ID if needed // try { // await db.whatsappMessage.create({ // data: { // id: outgoingTempId, // Use temp ID or let Prisma generate one // whatsappId: `temp_${outgoingTempId}`, // Placeholder until real ID known (if ever) // senderWaId: messageEntry.metadata.phone_number_id, // Your WABA ID // recipientWaId: senderWaId, // User's WA ID // body: replyText, // type: 'text', // direction: 'outgoing', // timestamp: new Date(), // Time reply was initiated // status: 'sent', // Initial status assumption // }, // }); // logger.info({ tempId: outgoingTempId }, 'Outgoing message placeholder saved'); // } catch (dbError) { // logger.error({ error: dbError }, 'Failed to save outgoing message placeholder'); // } } catch (replyError) { logger.error({ error: replyError, whatsappId: messageId }, 'Failed to send WhatsApp reply'); // Let SNS retry for potentially transient SDK errors return { statusCode: 500, body: 'Internal Server Error sending reply' }; } // Acknowledge receipt to SNS if everything up to here is successful // (or if DB errors are logged but not fatal) return { statusCode: 200, body: 'Notification processed successfully' } } catch (error) { // Catch errors during parsing, validation, or unhandled DB/reply errors logger.error({ error, snsMessage }, 'Error processing SNS Notification message') if (error instanceof z.ZodError) { return { statusCode: 400, body: 'Bad Request: Invalid WhatsApp payload structure' }; } // Return 500 for other processing errors to allow SNS retries return { statusCode: 500, body: 'Internal Server Error processing notification' } } } // ... rest of handler ...
Key Changes:
- Imported
db
fromsrc/lib/db
. - Added a
try...catch
block arounddb.whatsappMessage.create
to save the incoming message details. - Converted the Meta timestamp (string Unix seconds) to a JavaScript
Date
object. - Included commented-out example logic for saving an outgoing message placeholder, acknowledging that getting the final status/ID might require separate mechanisms (like status webhooks from Meta/AWS).
- Refined error handling to distinguish between fatal errors (like validation) and potentially retryable errors (like DB or SDK failures).
- Imported
Remember to configure your DATABASE_URL
environment variable in .env
for Prisma to connect to your database.