Developer Guide: Implementing Vonage SMS in RedwoodJS with Node.js
This guide provides a step-by-step walkthrough for integrating Vonage SMS functionality into your RedwoodJS application. You'll learn how to send outbound SMS messages (ideal for notifications or marketing campaigns) and receive inbound SMS messages via webhooks, leveraging the power of RedwoodJS's full-stack architecture and the Vonage Messages API.
By the end of this guide, you will have a RedwoodJS application capable of:
- Sending SMS messages programmatically using the Vonage Node.js SDK.
- Receiving incoming SMS messages sent to your Vonage virtual number via a secure webhook.
- Storing message logs in a database using Prisma.
- Handling configuration securely using environment variables.
This setup solves the common need for applications to communicate with users via SMS for alerts, verification, or engagement, providing a robust and scalable solution.
Prerequisites:
- Node.js: Version 20 or higher (Check with
node -v
). We recommend using nvm to manage Node versions. - Yarn: Yarn Classic (v1.x) (Check with
yarn -v
). - Vonage API Account: Sign up for free at Vonage API Dashboard. You'll need your API Key and Secret.
- Vonage Virtual Number: Purchase an SMS-capable virtual number through the Vonage Dashboard.
- ngrok: A tool to expose your local development server to the internet for webhook testing. A free account is sufficient. Download and set it up from ngrok.com.
Project Overview and Goals
We will build a RedwoodJS application with basic SMS functionality:
- API Service: A RedwoodJS service on the API side to encapsulate Vonage interactions.
- GraphQL Mutation: An endpoint to trigger sending an SMS message.
- Webhook Handler: A RedwoodJS function to receive incoming SMS messages from Vonage.
- Database Logging: Using Prisma to log basic details of sent and received messages.
Technologies:
- RedwoodJS: A full-stack JavaScript/TypeScript framework for the web. It provides structure, tooling (GraphQL, Prisma, Jest), and conventions for rapid development.
- Node.js: The JavaScript runtime environment RedwoodJS runs on.
- Vonage Messages API: Vonage's unified API for sending and receiving messages across various channels (we'll focus on SMS).
@vonage/server-sdk
: The official Vonage Node.js SDK for interacting with the API.- Prisma: RedwoodJS's default ORM for database interactions.
- GraphQL: Used by RedwoodJS for API communication between the web and API sides.
- ngrok: For local webhook development and testing.
System Architecture:
[User] <--> [Redwood Web Frontend (React)] <--> [Redwood API (GraphQL)]
|
v
+------------------------------------------<-- [Vonage Service (Node.js)] --> [Vonage API] --> [SMS Network] --> [Recipient Phone]
| ^
| (Webhook Trigger) | (API Call)
v |
[Vonage Webhook Function (Node.js)]---------------------+
|
v
[Database (Prisma)]
1. Setting up the Project
Let's create a new RedwoodJS project and configure the necessary Vonage components.
1.1 Create RedwoodJS App:
Open your terminal and run the Create Redwood App command. We'll use TypeScript (default) and initialize a git repository.
# Choose a name for your project (e.g., redwood-vonage-sms)
yarn create redwood-app redwood-vonage-sms
# Follow the prompts:
# - Select your preferred language: TypeScript (press Enter for default)
# - Do you want to initialize a git repo?: yes (press Enter for default)
# - Enter a commit message: Initial commit (press Enter for default)
# - Do you want to run yarn install?: yes (press Enter for default)
# Navigate into your new project directory
cd redwood-vonage-sms
1.2 Environment Variables Setup:
Securely storing API keys and sensitive information is crucial. RedwoodJS uses .env
files for this.
-
Create a
.env
file in the root of your project:touch .env
-
Add the following environment variables to your
.env
file. We'll get these values in the next steps.# .env VONAGE_API_KEY=YOUR_VONAGE_API_KEY VONAGE_API_SECRET=YOUR_VONAGE_API_SECRET VONAGE_APPLICATION_ID=YOUR_VONAGE_APPLICATION_ID VONAGE_PRIVATE_KEY_PATH=./private.key # Path to the private key file # Alternatively, provide the key content directly (useful for deployment) # VONAGE_PRIVATE_KEY_CONTENT=""-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"" VONAGE_VIRTUAL_NUMBER=YOUR_VONAGE_VIRTUAL_NUMBER # e.g., 14155551212
-
Important: Add
.env
andprivate.key
to your.gitignore
file to prevent committing secrets. The default RedwoodJS.gitignore
should already include.env
, but double-check and addprivate.key
.# .gitignore # ... other entries .env private.key *.private.key
1.3 Vonage Account Setup:
- API Key & Secret: Log in to your Vonage API Dashboard. Your API Key and Secret are displayed on the main page. Copy these into your
.env
file. - Virtual Number: Navigate to ""Numbers"" > ""Buy numbers"" to purchase an SMS-capable number if you don't have one. Copy this number (in E.164 format, e.g.,
14155551212
) intoVONAGE_VIRTUAL_NUMBER
in your.env
file. - Set Default SMS API: Go to your Account Settings in the Dashboard. Under ""API settings"" > ""SMS settings"", ensure that ""Default SMS Setting"" is set to Messages API. This is crucial for using the
@vonage/server-sdk
features correctly. Save the changes.
1.4 Create Vonage Application:
Vonage Applications group your numbers and configurations.
- In the Vonage Dashboard, navigate to ""Applications"" > ""Create a new application"".
- Give it a name (e.g., ""RedwoodJS SMS App"").
- Click ""Generate public and private key"". Immediately save the
private.key
file that downloads. Save it in the root of your RedwoodJS project (or updateVONAGE_PRIVATE_KEY_PATH
in.env
if you save it elsewhere, or useVONAGE_PRIVATE_KEY_CONTENT
). - Enable the ""Messages"" capability.
- You'll need placeholder URLs for now. Enter temporary HTTPS URLs (like
https://example.com/webhooks/inbound
andhttps://example.com/webhooks/status
). We will update these later with our ngrok URL during development.- Inbound URL:
https://example.com/webhooks/inbound
- Status URL:
https://example.com/webhooks/status
- Inbound URL:
- Click ""Generate new application"".
- Copy the generated Application ID into
VONAGE_APPLICATION_ID
in your.env
file. - Link Your Number: Go back to the ""Applications"" list, find your new application, and click ""Link"" under the ""Linked numbers"" column. Select your Vonage virtual number and link it.
1.5 Install Vonage SDK:
Install the Vonage Node.js SDK specifically in the api
workspace.
yarn workspace api add @vonage/server-sdk
2. Implementing Core Functionality (Sending SMS)
We'll create a RedwoodJS service to handle sending SMS messages via Vonage.
2.1 Create SMS Service:
Use the RedwoodJS CLI to generate the service files.
yarn rw g service sms
This creates api/src/services/sms/sms.ts
and related test/scenario files.
2.2 Configure Vonage Client:
It's good practice to initialize the Vonage client centrally. We can create a utility file for this.
-
Create
api/src/lib/vonage.ts
:// api/src/lib/vonage.ts import { Vonage } from '@vonage/server-sdk' import { logger } from 'src/lib/logger' // Redwood's built-in logger import fs from 'fs' // Import Node.js filesystem module if reading key needed import path from 'path' // Import Node.js path module let vonageInstance: Vonage | null = null export const getVonageClient = (): Vonage => { if (!vonageInstance) { const apiKey = process.env.VONAGE_API_KEY const apiSecret = process.env.VONAGE_API_SECRET const applicationId = process.env.VONAGE_APPLICATION_ID const privateKeyContent = process.env.VONAGE_PRIVATE_KEY_CONTENT const privateKeyPath = process.env.VONAGE_PRIVATE_KEY_PATH if (!apiKey || !apiSecret || !applicationId) { logger.error('Missing Vonage API Key, Secret, or Application ID in environment variables.') throw new Error('Vonage client API credential configuration is incomplete.') } // Determine the private key source: prioritize content over path let privateKey: string | Buffer if (privateKeyContent) { logger.info('Using Vonage private key from VONAGE_PRIVATE_KEY_CONTENT.') privateKey = privateKeyContent.replace(/\\n/g, '\n') // Ensure newlines are correct if escaped in env var } else if (privateKeyPath) { logger.info(`Using Vonage private key from path: ${privateKeyPath}`) // Resolve the path relative to the project root (where Redwood usually runs) const absolutePath = path.resolve(process.cwd(), privateKeyPath) if (!fs.existsSync(absolutePath)) { logger.error(`Private key file not found at path: ${absolutePath}`) throw new Error(`Vonage private key file not found at specified path: ${privateKeyPath}`) } // The SDK can handle the path directly, or you could read it: // privateKey = fs.readFileSync(absolutePath); privateKey = absolutePath // Pass the path to the SDK } else { logger.error('Missing Vonage Private Key configuration (neither VONAGE_PRIVATE_KEY_CONTENT nor VONAGE_PRIVATE_KEY_PATH found).') throw new Error('Vonage private key configuration is incomplete.') } try { vonageInstance = new Vonage({ apiKey, apiSecret, applicationId, privateKey, // SDK accepts path string or key content Buffer/string }) logger.info('Vonage client initialized successfully.') } catch (error) { logger.error({ error }, 'Failed to initialize Vonage client') throw error // Re-throw to prevent using a non-functional client } } return vonageInstance }
- Why: This creates a singleton pattern for the Vonage client, ensuring we don't re-initialize it unnecessarily on every request. It centralizes credential validation and error handling during initialization. It now prioritizes reading the private key content from
VONAGE_PRIVATE_KEY_CONTENT
if available, falling back to the path specified inVONAGE_PRIVATE_KEY_PATH
. Redwood's logger provides structured logging.
- Why: This creates a singleton pattern for the Vonage client, ensuring we don't re-initialize it unnecessarily on every request. It centralizes credential validation and error handling during initialization. It now prioritizes reading the private key content from
2.3 Implement sendSms
Function:
Now, edit the generated service file (api/src/services/sms/sms.ts
) to add the sending logic.
// api/src/services/sms/sms.ts
import type { MutationResolvers } from 'types/graphql'
import { logger } from 'src/lib/logger'
import { getVonageClient } from 'src/lib/vonage'
// Import db later when we add logging
// import { db } from 'src/lib/db'
interface SendSmsInput {
to: string
text: string
}
export const sendSms = async ({ to, text }: SendSmsInput): Promise<{ success: boolean; messageId?: string; error?: string }> => {
logger.info({ to, textLength: text.length }, 'Attempting to send SMS')
const vonage = getVonageClient() // Get initialized client
const fromNumber = process.env.VONAGE_VIRTUAL_NUMBER
if (!fromNumber) {
logger.error('VONAGE_VIRTUAL_NUMBER is not set in environment variables.')
return { success: false, error: 'Vonage sender number not configured.' }
}
// Basic validation (more robust validation is recommended)
if (!to || !text) {
logger.warn('Missing recipient (to) or text content.')
return { success: false, error: 'Recipient phone number and text message are required.' }
}
// Consider adding E.164 format validation for 'to' number
try {
const resp = await vonage.messages.send({
message_type: 'text',
to: to, // Recipient number
from: fromNumber, // Your Vonage virtual number
channel: 'sms',
text: text, // The message content
})
logger.info({ messageId: resp.message_uuid }, 'SMS sent successfully via Vonage')
// --- TODO: Add database logging here (Section 6) ---
return { success: true, messageId: resp.message_uuid }
} catch (error) {
logger.error({ error, to }, 'Failed to send SMS via Vonage')
// Attempt to provide a more specific error message if possible
const errorMessage = error.response?.data?.title || error.message || 'Unknown error sending SMS.'
return { success: false, error: errorMessage }
}
}
// Note: We define the GraphQL Mutation in the SDL file next.
// The actual resolver function will be automatically picked up by Redwood
// based on the service function name matching the mutation name.
- Why: This service function encapsulates the logic for sending an SMS. It retrieves the Vonage client, performs basic validation, constructs the payload for the Vonage Messages API, calls the
send
method, and handles potential errors usingtry...catch
. It returns a structured response indicating success or failure. Logging provides visibility into the process.
3. Building the API Layer (GraphQL Mutation)
RedwoodJS uses GraphQL for its API. Let's define a mutation to expose our sendSms
service function.
3.1 Define GraphQL Schema (SDL):
Edit the schema definition file api/src/graphql/sms.sdl.ts
.
// api/src/graphql/sms.sdl.ts
export const schema = gql`
type SmsResponse {
success: Boolean!
messageId: String
error: String
}
type Mutation {
""""""Sends an SMS message using Vonage.""""""
sendSms(to: String!, text: String!): SmsResponse! @skipAuth # Use @requireAuth in production!
}
`
- Why: This defines the structure of our GraphQL API endpoint.
SmsResponse
: Specifies the fields returned by the mutation.Mutation
: Defines the available mutations.sendSms
takesto
andtext
as non-nullable String arguments and returns anSmsResponse
.@skipAuth
: For development only. This disables authentication. In a real application, you would replace this with@requireAuth
to ensure only logged-in users can trigger the mutation, implementing RedwoodJS Authentication.
3.2 Test the Mutation:
-
Start the development server:
yarn rw dev
-
Open your browser to the RedwoodJS GraphQL Playground:
http://localhost:8911/graphql
. -
In the left panel, enter the following mutation (replace
`YOUR_REAL_PHONE_NUMBER`
with your actual phone number in E.164 format):mutation SendTestSms { sendSms(to: ""`YOUR_REAL_PHONE_NUMBER`"", text: ""Hello from RedwoodJS and Vonage!"") { success messageId error } }
-
Click the ""Play"" button.
You should receive an SMS on your phone, and the GraphQL response should look like:
{
""data"": {
""sendSms"": {
""success"": true,
""messageId"": ""some-unique-message-uuid"",
""error"": null
}
}
}
If you get success: false
, check the error
message and review the terminal output where yarn rw dev
is running for logs from api/src/services/sms/sms.ts
. Common issues include incorrect API credentials, wrong phone number formats, or the private key file not being found/read correctly.
3.3 curl
Example:
You can also test the GraphQL endpoint using curl
:
curl http://localhost:8911/graphql \
-H 'Content-Type: application/json' \
--data-raw '{""query"":""mutation SendTestSms { sendSms(to: \""`YOUR_REAL_PHONE_NUMBER`\"", text: \""Hello via curl!\"") { success messageId error } }""}'
Replace `YOUR_REAL_PHONE_NUMBER`
accordingly.
4. Integrating Third-Party Services (Receiving SMS via Webhooks)
To receive incoming SMS messages, Vonage needs to send data to a URL in our application (a webhook).
4.1 Expose Localhost with ngrok:
Since your RedwoodJS app is running locally, Vonage can't reach it directly. We use ngrok
to create a secure tunnel.
-
If
yarn rw dev
is running, stop it (Ctrl+C). -
Start
ngrok
to forward to Redwood's API port (usually 8911):# Make sure you've signed up and configured your ngrok authtoken ngrok http 8911
-
ngrok
will display a public ""Forwarding"" URL (e.g.,https://<unique-id>.ngrok-free.app
). Copy the HTTPS version of this URL.
4.2 Update Vonage Application Webhook URLs:
- Go back to your Vonage Application settings in the Dashboard (""Applications"" > Your App Name > Edit).
- Update the Messages capability URLs:
- Inbound URL:
YOUR_NGROK_HTTPS_URL/webhooks/inbound
- Status URL:
YOUR_NGROK_HTTPS_URL/webhooks/status
- (Replace
YOUR_NGROK_HTTPS_URL
with the URL you copied from ngrok).
- Inbound URL:
- Make sure the method for both is
POST
. - Save the changes.
4.3 Create RedwoodJS Webhook Handler:
RedwoodJS functions are perfect for webhooks. Generate a function:
yarn rw g function vonageWebhook
This creates api/src/functions/vonageWebhook.ts
.
4.4 Implement Webhook Logic:
Modify api/src/functions/vonageWebhook.ts
to handle incoming messages and status updates. This version includes logic to handle both JSON and form-urlencoded payloads.
// api/src/functions/vonageWebhook.ts
import type { APIGatewayEvent, Context } from 'aws-lambda'
import { logger } from 'src/lib/logger'
import querystring from 'node:querystring' // Use Node's built-in querystring parser
// Import db later when we add logging
// import { db } from 'src/lib/db'
/**
* Parses the request body based on Content-Type header.
* Handles JSON and form-urlencoded data.
*/
const parseRequestBody = (event: APIGatewayEvent): Record<string_ any> => {
const contentType = event.headers['content-type'] || event.headers['Content-Type'] || ''
const body = event.body || ''
if (!body) {
return {}
}
try {
if (contentType.includes('application/json')) {
return JSON.parse(body)
} else if (contentType.includes('application/x-www-form-urlencoded')) {
return querystring.parse(body)
} else {
// Attempt JSON parse as a fallback, or handle other types if needed
logger.warn(`Unexpected Content-Type: ${contentType}. Attempting JSON parse.`)
return JSON.parse(body)
}
} catch (error) {
logger.error({ error, body, contentType }, 'Failed to parse webhook request body')
// Return empty object or throw, depending on how you want to handle parse failures
return {}
}
}
/**
* The handler function is your serverless function's execution entry point.
* You have access to the request context like headers, path, query parameters, and body.
*
* @see https://redwoodjs.com/docs/functions
*/
export const handler = async (event: APIGatewayEvent, _context: Context) => {
logger.info({ path: event.path }, 'Received request on Vonage webhook handler')
// Vonage expects a 200 OK response quickly, otherwise it will retry.
// Respond immediately and process asynchronously if needed, though for logging it's usually fast enough.
try {
// Parse the body based on content type
const body = parseRequestBody(event)
logger.debug({ body }, 'Webhook payload received and parsed')
// Check if parsing failed (returned empty object from our helper)
if (Object.keys(body).length === 0 && event.body) {
logger.error('Webhook body parsing resulted in empty object, check parseRequestBody logic and logs.')
// Consider returning 400 Bad Request if parsing is essential
// return { statusCode: 400, body: JSON.stringify({ error: 'Invalid request body' }) }
}
// Differentiate based on the path Vonage hits
if (event.path.endsWith('/webhooks/inbound')) {
// --- Handle Incoming SMS Message ---
logger.info('Processing inbound SMS webhook')
// Extract fields carefully, accounting for potential differences between JSON/form data key names if any
const { msisdn, to, messageId, text, type, 'message-timestamp': timestamp } = body
if (type === 'text' && msisdn && to && messageId && text) {
logger.info(
{ from: msisdn, to, messageId, textLength: text.length },
'Received valid inbound SMS'
)
// --- TODO: Add database logging here (Section 6) ---
// --- TODO: Add business logic here (e.g., auto-reply, trigger action) ---
} else {
logger.warn({ body }, 'Received inbound webhook with unexpected format or missing fields')
}
} else if (event.path.endsWith('/webhooks/status')) {
// --- Handle Message Status Update ---
logger.info('Processing message status webhook')
// Status webhooks often use snake_case keys
const { message_uuid, status, timestamp, to, from, error } = body
if (message_uuid) { // Check if the primary identifier exists
logger.info({ message_uuid, status, to }, 'Received message status update')
// --- TODO: Update message status in the database (Section 6) ---
if (error) {
logger.error({ message_uuid, error }, 'Message delivery failed')
}
} else {
logger.warn({ body }, 'Received status webhook without message_uuid')
}
} else {
logger.warn(`Webhook called on unexpected path: ${event.path}`)
}
// Always return 200 OK to Vonage to prevent retries
return {
statusCode: 200,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: 'Webhook received successfully' }),
}
} catch (error) {
logger.error({ error, requestBody: event.body }, 'Error processing Vonage webhook') // Log raw body on error
// Still return 200 to Vonage if possible, but log the error thoroughly
// If the error is critical (e.g., unhandled exception during processing), a 500 might be necessary, but Vonage will retry.
return {
statusCode: 200, // Or 500 if absolutely necessary
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ error: 'Failed to process webhook' }),
}
}
}
- Why: This function acts as the endpoint for Vonage. It now includes a
parseRequestBody
helper to handle bothapplication/json
andapplication/x-www-form-urlencoded
content types, making it more robust. It uses theevent.path
to determine if it's an inbound message or a status update. It parses the payload, logs relevant information, and importantly, returns a200 OK
status code promptly to acknowledge receipt to Vonage. Error handling ensures issues are logged without causing Vonage to endlessly retry (unless a 500 is returned).
4.5 Test Receiving SMS:
- Ensure
ngrok
is running and pointing to port 8911. - Start the RedwoodJS dev server:
yarn rw dev
. - Send an SMS from your physical phone to your Vonage virtual number.
- Check the logs: Look at the terminal running
yarn rw dev
. You should see log entries fromapi/src/functions/vonageWebhook.ts
indicating an ""inbound SMS"" was received, along with details like the sender number (msisdn
) and the message text. Check for any parsing warnings or errors. - Check ngrok console: You can also inspect the request details in the ngrok web interface (usually
http://127.0.0.1:4040
), including theContent-Type
header sent by Vonage.
5. Implementing Error Handling, Logging, and Retry Mechanisms
- Error Handling: We've implemented basic
try...catch
blocks in the service (sendSms
) and the webhook handler. InsendSms
, we catch errors from the Vonage SDK and return a{ success: false, error: '...' }
object. In the webhook, we catch errors during processing but still try to return200 OK
to Vonage, logging the error internally. The webhook now also handles potential body parsing errors. More specific error types could be handled (e.g., distinguishing network errors from API errors from Vonage). - Logging: RedwoodJS's built-in Pino logger (
src/lib/logger
) is used. We log key events: attempts to send, success/failure of sending, reception of webhooks, specific details from payloads, and errors.logger.info
,logger.warn
,logger.error
, andlogger.debug
are used appropriately. For production, configure log levels and destinations (e.g., sending logs to a service like Datadog or Logflare). - Retry Mechanisms:
- Sending: The current
sendSms
doesn't implement retries. For critical messages, you could wrap thevonage.messages.send
call in a retry loop with exponential backoff (using libraries likeasync-retry
orp-retry
) for transient network errors or temporary Vonage API issues. - Receiving: Vonage handles retries automatically if your webhook endpoint doesn't return a
200 OK
status within a reasonable time (a few seconds). Our handler is designed to return 200 quickly, even if background processing fails, to prevent unnecessary Vonage retries.
- Sending: The current
Example: Testing Error Scenario (Send SMS)
Modify the sendSms
service temporarily to force an error, e.g., provide an invalid to
number format known to cause Vonage API errors, or temporarily change the API key in .env
to something invalid. Then trigger the sendSms
mutation and observe the logged error and the { success: false, ... }
response.
6. Creating a Database Schema and Data Layer
Let's log sent and received messages to the database using Prisma.
6.1 Define Prisma Schema:
Edit api/db/schema.prisma
. Add a SmsLog
model.
// api/db/schema.prisma
datasource db {
provider = ""sqlite"" // Or ""postgresql"", ""mysql""
url = env(""DATABASE_URL"")
}
generator client {
provider = ""prisma-client-js""
binaryTargets = ""native""
}
// Define your database tables here.
// See https://redwoodjs.com/docs/schema-migrations for more info.
model SmsLog {
id String @id @default(cuid())
direction String // ""OUTBOUND"" or ""INBOUND""
vonageId String @unique // message_uuid from Vonage, or a generated unique ID for failures
fromNumber String
toNumber String
body String? // The message text
status String? // e.g., ""SUBMITTED"", ""DELIVERED"", ""FAILED"", ""RECEIVED""
vonageStatus String? // Raw status from Vonage status webhook
error String? // Error message if sending/processing failed
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
- Why: This schema defines a table to store essential information about each SMS message, including its direction, unique Vonage ID, sender/recipient, content, status, and timestamps. The
vonageId
is unique to prevent duplicate entries from retried webhooks (though upsert logic is better) and uses a CUID as a fallback if the Vonage ID isn't obtained.
6.2 Create and Run Migration:
Apply the schema changes to your database.
# Create a new migration file based on schema changes
yarn rw prisma migrate dev --name add-sms-log
# This command also applies the migration
6.3 Update Service and Webhook to Log Data:
Modify the sendSms
service and the vonageWebhook
function to interact with the database. You might need to add the cuid
package: yarn workspace api add cuid
.
-
api/src/services/sms/sms.ts
(sendSms):// api/src/services/sms/sms.ts import type { MutationResolvers } from 'types/graphql' import { logger } from 'src/lib/logger' import { getVonageClient } from 'src/lib/vonage' import { db } from 'src/lib/db' // Import db import cuid from 'cuid' // Import cuid for generating unique IDs interface SendSmsInput { to: string text: string } export const sendSms = async ({ to, text }: SendSmsInput): Promise<{ success: boolean; messageId?: string; error?: string }> => { logger.info({ to, textLength: text.length }, 'Attempting to send SMS') const vonage = getVonageClient() const fromNumber = process.env.VONAGE_VIRTUAL_NUMBER if (!fromNumber) { logger.error('VONAGE_VIRTUAL_NUMBER is not set in environment variables.') return { success: false, error: 'Vonage sender number not configured.' } } if (!to || !text) { logger.warn('Missing recipient (to) or text content.') return { success: false, error: 'Recipient phone number and text message are required.' } } let messageId: string | undefined = undefined; let success = false; let errorMessage: string | undefined = undefined; // Generate a placeholder ID in case the send fails before we get one from Vonage const placeholderId = `failed-${cuid()}` try { const resp = await vonage.messages.send({ message_type: 'text', to: to, from: fromNumber, channel: 'sms', text: text, }); messageId = resp.message_uuid; success = true; logger.info({ messageId }, 'SMS sent successfully via Vonage'); } catch (error) { logger.error({ error, to }, 'Failed to send SMS via Vonage'); errorMessage = error.response?.data?.title || error.message || 'Unknown error sending SMS.'; success = false; // Keep messageId as undefined, we'll use the placeholder in the log } // --- Log to Database --- const finalVonageId = messageId || placeholderId; try { await db.smsLog.create({ data: { direction: 'OUTBOUND', // Use actual ID if available, otherwise use the generated unique placeholder vonageId: finalVonageId, fromNumber: fromNumber, // Checked non-null earlier toNumber: to, body: text, status: success ? 'SUBMITTED' : 'FAILED', // Initial status error: errorMessage, }, }); logger.debug({ vonageId: finalVonageId }, 'Logged outbound SMS attempt to database'); } catch (dbError) { logger.error({ dbError, vonageId: finalVonageId }, 'Failed to log outbound SMS to database'); // Decide if this failure should impact the overall success status returned to the user } // --- End Log to Database --- // Return the actual messageId if successful, otherwise it remains undefined return { success, messageId, error: errorMessage }; }
-
api/src/functions/vonageWebhook.ts
(handler):// api/src/functions/vonageWebhook.ts import type { APIGatewayEvent, Context } from 'aws-lambda' import { logger } from 'src/lib/logger' import { db } from 'src/lib/db' // Import db import querystring from 'node:querystring' // (Keep the parseRequestBody helper function from section 4.4 here) const parseRequestBody = (event: APIGatewayEvent): Record<string_ any> => { const contentType = event.headers['content-type'] || event.headers['Content-Type'] || '' const body = event.body || '' if (!body) { return {} } try { if (contentType.includes('application/json')) { return JSON.parse(body) } else if (contentType.includes('application/x-www-form-urlencoded')) { return querystring.parse(body) } else { logger.warn(`Unexpected Content-Type: ${contentType}. Attempting JSON parse.`) return JSON.parse(body) } } catch (error) { logger.error({ error, body, contentType }, 'Failed to parse webhook request body') return {} } } export const handler = async (event: APIGatewayEvent, _context: Context) => { logger.info({ path: event.path }, 'Received request on Vonage webhook handler') try { const body = parseRequestBody(event) logger.debug({ body }, 'Webhook payload received and parsed') if (Object.keys(body).length === 0 && event.body) { logger.error('Webhook body parsing resulted in empty object, check parseRequestBody logic and logs.') // Potentially return 400 here if needed } if (event.path.endsWith('/webhooks/inbound')) { logger.info('Processing inbound SMS webhook') const { msisdn, to, messageId, text, type } = body if (type === 'text' && msisdn && to && messageId && text) { logger.info({ from: msisdn, to, messageId, textLength: text.length }, 'Received valid inbound SMS') try { await db.smsLog.create({ data: { direction: 'INBOUND', vonageId: messageId, fromNumber: msisdn, toNumber: to, body: text, status: 'RECEIVED', // Status for inbound messages }, }) logger.debug({ vonageId: messageId }, 'Logged inbound SMS to database') } catch (dbError) { // Handle potential duplicate messageId errors if Vonage retries if (dbError.code === 'P2002' && dbError.meta?.target?.includes('vonageId')) { logger.warn({ vonageId: messageId }, 'Attempted to log duplicate inbound SMS (vonageId exists). Ignoring.') } else { logger.error({ dbError, vonageId: messageId }, 'Failed to log inbound SMS to database') } } // Add business logic here } else { logger.warn({ body }, 'Received inbound webhook with unexpected format or missing fields') } } else if (event.path.endsWith('/webhooks/status')) { logger.info('Processing message status webhook') const { message_uuid, status, timestamp, to, from, error } = body if (message_uuid) { logger.info({ message_uuid, status, to }, 'Received message status update') try { await db.smsLog.update({ where: { vonageId: message_uuid }, data: { status: status?.toUpperCase(), // Normalize status (e.g., 'delivered' -> 'DELIVERED') vonageStatus: status, // Store raw status from Vonage error: error ? JSON.stringify(error) : undefined, // Store error details if present updatedAt: timestamp ? new Date(timestamp) : new Date(), // Use Vonage timestamp if available }, }) logger.debug({ vonageId: message_uuid, status }, 'Updated SMS status in database') } catch (dbError) { // Log error if update fails (e.g., record not found) logger.error({ dbError, vonageId: message_uuid }, 'Failed to update SMS status in database') } if (error) { logger.error({ message_uuid, error }, 'Message delivery failed according to status update') } } else { logger.warn({ body }, 'Received status webhook without message_uuid') } } else { logger.warn(`Webhook called on unexpected path: ${event.path}`) } return { statusCode: 200, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'Webhook received successfully' }), } } catch (error) { logger.error({ error, requestBody: event.body }, 'Error processing Vonage webhook') return { statusCode: 200, // Still return 200 to prevent Vonage retries unless absolutely necessary headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ error: 'Failed to process webhook' }), } } }
-
Why: The
sendSms
service now logs the attempt to theSmsLog
table, recording whether it was initially submitted or failed. ThevonageWebhook
handler logs incoming messages (INBOUND
) and updates the status of existing outbound messages based on status webhooks (OUTBOUND
). It usesupsert
orupdate
logic (hereupdate
for status,create
for inbound with duplicate check) to handle message states correctly, including potential retries from Vonage for inbound messages. Error handling for database operations is included.