This guide provides a complete walkthrough for building a Next.js application capable of receiving and processing inbound SMS messages sent via the Infobip platform. By implementing a webhook endpoint, your application can handle replies, commands, or any incoming SMS traffic directed to your Infobip number, enabling true two-way messaging conversations.
We'll cover setting up your Next.js project, creating the API route to act as a webhook receiver, configuring Infobip to forward messages, handling basic security, processing incoming messages, storing them (optionally), and deploying the application.
Project Goals:
- Create a Next.js application with a dedicated API endpoint to receive incoming SMS messages from Infobip.
- Securely handle Infobip credentials and webhook secrets.
- Configure Infobip to route inbound messages (Mobile Originated - MO) to the Next.js webhook.
- Implement basic security validation for incoming webhook requests.
- Process and log incoming SMS data.
- (Optional) Store received messages in a database using Prisma.
- Provide instructions for local testing, deployment, and verification.
Technologies Used:
- Next.js: React framework for building the web application and API endpoint. Chosen for its ease of development, built-in API routes, and robust ecosystem.
- Node.js: The runtime environment for Next.js.
- Infobip: The CPaaS provider used for sending and, crucially here, receiving SMS messages.
- Prisma (Optional): Modern ORM for database interaction. Chosen for its developer experience and type safety.
- Ngrok (for testing): Tool to expose local development servers to the internet for webhook testing.
System Architecture:
<!--
Replace this block with a static image or a textual description of the architecture:
End User --(Sends SMS)--> Infobip Platform --(Forwards MO SMS via HTTP POST)--> Next.js App / Webhook Endpoint --(Processes Request)--> [Optional Database]
The Next.js App sends a 200 OK response back to the Infobip Platform.
-->
Prerequisites:
- An active Infobip account with a number capable of receiving SMS messages.
- Node.js (v18 or later recommended) and npm/yarn installed.
- Basic understanding of JavaScript, Node.js, Next.js, and REST APIs.
- Access to a terminal or command prompt.
- (Optional) A database (e.g., PostgreSQL, MySQL, SQLite) if implementing the Prisma storage layer.
- (Optional) Ngrok installed for local webhook testing.
1. Setting up the Project
Let's start by creating a new Next.js project and setting up the basic structure and environment variables.
-
Create a Next.js App: Open your terminal and run the following command, replacing
infobip-inbound-app
with your desired project name. We'll use TypeScript for better type safety.npx create-next-app@latest infobip-inbound-app --typescript --eslint --tailwind --src-dir --app --import-alias ""@/*""
Follow the prompts (you can accept the defaults). This command sets up a new Next.js project using the App Router (
--app
), TypeScript, ESLint, and Tailwind CSS (optional but common). -
Navigate to Project Directory:
cd infobip-inbound-app
-
Set up Environment Variables: Create a file named
.env.local
in the root of your project. This file will store sensitive information like API keys and secrets. It's crucial not to commit this file to version control (it's included in the default.gitignore
created bycreate-next-app
).# .env.local # Infobip Configuration (Obtain from Infobip Portal - see Section 4) # NOTE: BASE_URL and API_KEY are primarily needed for *sending* messages via the API. # They are not strictly required just to *receive* webhooks if you only implement inbound handling. # Include them if you plan to extend this app to send replies. INFOBIP_BASE_URL=your_infobip_api_base_url.api.infobip.com INFOBIP_API_KEY=YourInfobipApiKey # Webhook Security (Choose a strong, unique secret) # This MUST match the 'Password' you configure in Infobip's webhook Basic Auth settings. INFOBIP_WEBHOOK_SECRET=a_very_strong_and_secret_string_you_generate # (Optional) Database Connection (If using Prisma - see Section 6) # Example for PostgreSQL: DATABASE_URL=""postgresql://user:password@host:port/database?schema=public""
INFOBIP_BASE_URL
&INFOBIP_API_KEY
: Obtain these from your Infobip account dashboard. Needed if you extend the app to send SMS replies.INFOBIP_WEBHOOK_SECRET
: This is a secret you define. It acts as the password for Basic Authentication to secure your webhook endpoint. Configure this exact value in the Infobip portal (Section 4).DATABASE_URL
: Add this only if you plan to implement the database layer (Section 6). Adjust the format based on your chosen database.
-
Project Structure: The
create-next-app
command with the--app
flag sets up the App Router structure. Our primary focus will be within thesrc/app/api/
directory where we'll create the webhook endpoint.
2. Implementing the Core Functionality (Webhook Endpoint)
The core of this application is the API route that listens for incoming POST requests from Infobip.
-
Create the API Route File: Create a new file at
src/app/api/infobip-webhook/route.ts
. Next.js automatically maps files namedroute.ts
within theapp
directory structure to API endpoints. This file will handle requests made to/api/infobip-webhook
. -
Implement the Basic Handler: Add the following code to
src/app/api/infobip-webhook/route.ts
:// src/app/api/infobip-webhook/route.ts import { NextRequest, NextResponse } from 'next/server'; import logger from '@/lib/logger'; // Assuming logger setup (Section 5) // Define the expected structure of an incoming Infobip SMS message // DISCLAIMER: This interface is illustrative. Always verify the exact 'SMS MO' // payload structure against the current official Infobip documentation. interface InfobipIncomingMessage { messageId?: string; from?: string; to?: string; text?: string; keyword?: string; receivedAt?: string; // Typically ISO 8601 format (e.g., ""2023-10-27T10:30:00.123Z"") // Add other fields like 'cleanText', 'smsCount', etc., as needed based on docs. } interface InfobipWebhookPayload { results: InfobipIncomingMessage[]; messageCount: number; pendingMessageCount: number; } export async function POST(req: NextRequest) { logger.info('Received request on /api/infobip-webhook'); try { // --- Security Validation (Basic Authentication) --- const expectedSecret = process.env.INFOBIP_WEBHOOK_SECRET; const authHeader = req.headers.get('authorization'); if (!expectedSecret) { logger.warn('INFOBIP_WEBHOOK_SECRET is not set in environment variables. Skipping webhook authentication.'); // Consider returning 500 or denying requests if auth is mandatory } else if (!authHeader || !authHeader.toLowerCase().startsWith('basic ')) { logger.error('Missing or invalid Authorization header'); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } else { // Decode Basic Auth: base64(username:password) const encodedCreds = authHeader.split(' ')[1]; if (!encodedCreds) { logger.error('Invalid Authorization header format (missing credentials)'); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const decodedCreds = Buffer.from(encodedCreds, 'base64').toString('utf-8'); const [username, password] = decodedCreds.split(':'); // Validate the password (which is our shared secret) if (password !== expectedSecret) { logger.error('Invalid webhook secret provided'); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } // NOTE: Infobip's required 'username' for Basic Auth can vary. // It might be your API Key ID, a fixed string like 'InfobipWebhook', // or potentially ignored if the password matches. // Check the Infobip portal configuration for the exact requirement. // You might add validation for 'username' here if needed. logger.info(`Webhook authentication successful for user: ${username}`); } // --- End Security Validation --- const body: InfobipWebhookPayload = await req.json(); logger.debug({ payload: body }, 'Webhook payload received'); // Use debug level for potentially large/sensitive data if (!body.results || !Array.isArray(body.results)) { logger.warn('Received invalid payload structure (missing or non-array results).'); // Acknowledge receipt but indicate potential issue. return NextResponse.json({ message: 'Acknowledged invalid payload structure' }, { status: 200 }); } if (body.results.length === 0) { logger.info('Received webhook with empty results array.'); // Acknowledge receipt of the ping/empty payload. return NextResponse.json({ message: 'Acknowledged empty payload' }, { status: 200 }); } // Process each incoming message // (Consider asynchronous processing if logic is complex/slow) for (const message of body.results) { logger.info({ from: message.from, msgId: message.messageId }, `Processing message`); // Implement your message processing logic here: // - Store the message (See Section 6) // - Check for keywords // - Trigger replies (Requires sending capabilities) // - Enqueue for background processing } // IMPORTANT: Always return a 2xx response to Infobip promptly // to acknowledge successful receipt of the webhook. // Failure may cause Infobip to retry delivery unnecessarily. return NextResponse.json({ message: 'Webhook received successfully' }, { status: 200 }); } catch (error) { // Handle potential errors during processing if (error instanceof SyntaxError) { // Error parsing JSON body - likely a malformed request logger.error('Failed to parse incoming JSON payload.', { error }); return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 }); } // Handle other errors (e.g., database errors during save, validation errors) logger.error('Error processing Infobip webhook', { error }); // Recommendation: Return 200 OK even for internal processing errors *after* // successful receipt and authentication. This acknowledges the message // was received and prevents Infobip retries for issues within your system // that a retry might not fix. Log the error thoroughly for investigation. // Only return 500 if the server is fundamentally unable to process requests // *before* even validating/parsing. return NextResponse.json({ message: 'Acknowledged, but internal processing error occurred.' }, { status: 200 }); // Alternative (if retries are desired for specific internal errors): // return NextResponse.json({ error: 'Internal server error during processing' }, { status: 500 }); } } // Optional: Handle GET requests or other methods if needed export async function GET(req: NextRequest) { logger.warn(`Received unsupported GET request on webhook endpoint.`); return NextResponse.json({ message: 'Method Not Allowed' }, { status: 405 }); }
InfobipWebhookPayload
/InfobipIncomingMessage
: Interfaces defining the expected data structure. Added a disclaimer to check official Infobip documentation.POST
Function: HandlesPOST
requests from Infobip.- Security Validation: Implements Basic Authentication, comparing the provided password against
INFOBIP_WEBHOOK_SECRET
. Added notes about verifying the requiredusername
from Infobip's settings. - Parsing:
req.json()
parses the incoming request body. Includes basic checks forbody.results
. - Processing Loop: Iterates through the
results
array. - Logging: Uses a logger (assumed setup from Section 5).
- Response: Returns
200 OK
on successful receipt/processing. Returns401
for auth failures,400
for invalid JSON. Recommended200 OK
for internal processing errors after receipt to avoid unnecessary retries, but includes comments on the500
alternative.
3. Understanding the API Layer Contract
The API route src/app/api/infobip-webhook/route.ts
serves as the API layer for receiving inbound SMS. Here's a summary of its contract:
-
Endpoint:
POST /api/infobip-webhook
- This is the URL you will configure in the Infobip platform.
- Only the
POST
method is expected to receive message data.GET
or other methods should return405 Method Not Allowed
.
-
Authentication: Basic Authentication
- The endpoint expects an
Authorization: Basic <credentials>
header. - The
<credentials>
part is a Base64 encoded string ofusername:password
. - The
password
MUST be the value you set forINFOBIP_WEBHOOK_SECRET
. - The required
username
MUST be verified in your Infobip webhook configuration (it could be your API Key ID, a fixed string, or potentially ignored by Infobip if the password matches - confirm in their UI).
- The endpoint expects an
-
Request Body (Expected from Infobip):
- Content-Type:
application/json
- Structure (Illustrative - Always check current Infobip documentation):
{ ""results"": [ { ""messageId"": ""ABC-123-XYZ"", ""from"": ""15551234567"", ""to"": ""15559876543"", ""text"": ""Hello there!"", ""cleanText"": ""Hello there!"", ""keyword"": ""HELLO"", ""receivedAt"": ""2023-10-27T10:30:00.123Z"", ""smsCount"": 1 // ... potentially other fields like price, mccMnc, etc. } // ... potentially more messages if batched ], ""messageCount"": 1, ""pendingMessageCount"": 0 }
- Content-Type:
-
Response Body (Sent back to Infobip):
- Success (200 OK): Acknowledges receipt.
or (for empty/ping payloads):
{ ""message"": ""Webhook received successfully"" }
or (if internal processing failed post-receipt, following recommendation):{ ""message"": ""Acknowledged empty payload"" }
{ ""message"": ""Acknowledged, but internal processing error occurred."" }
- Authentication Error (401 Unauthorized): Incorrect or missing credentials.
{ ""error"": ""Unauthorized"" }
- Bad Request Error (400 Bad Request): Malformed JSON or invalid payload structure.
{ ""error"": ""Invalid JSON payload"" }
- Server Error (500 Internal Server Error): Use cautiously, potentially only for failures before request processing begins. Might trigger Infobip retries.
{ ""error"": ""Internal server error during processing"" }
- Success (200 OK): Acknowledges receipt.
-
Testing with cURL: (Run your Next.js app locally, expose with
ngrok
, replace placeholders)# Replace YOUR_NGROK_URL with your ngrok forwarding URL. # Replace YOUR_USERNAME with the username required by your Infobip config. # Replace YOUR_SECRET with the value of INFOBIP_WEBHOOK_SECRET. # Replace the JSON payload with a valid sample Infobip MO message. curl -X POST https://YOUR_NGROK_URL.ngrok.io/api/infobip-webhook \ -H ""Content-Type: application/json"" \ -H ""Authorization: Basic $(echo -n 'YOUR_USERNAME:YOUR_SECRET' | base64)"" \ -d '{ ""results"": [ { ""from"": ""15551112222"", ""to"": ""15553334444"", ""text"": ""Test message from cURL"", ""receivedAt"": ""2023-10-27T12:00:00Z"", ""messageId"": ""curl-test-msg-123"" } ], ""messageCount"": 1, ""pendingMessageCount"": 0 }'
(Remember: Verify the exact
YOUR_USERNAME
required by checking your Infobip webhook configuration settings.)
4. Integrating with Infobip
This is where you configure Infobip to send incoming SMS messages to your newly created webhook endpoint.
-
Obtain Infobip Credentials (If Sending Replies):
- Log in to your Infobip Portal.
- Navigate to the API key management section (often under ""Developers"" or account settings).
- Copy your API Key and Base URL. If you plan to send replies from your app, add these to your
.env.local
file (INFOBIP_API_KEY
,INFOBIP_BASE_URL
). Your Base URL looks something likexxxxx.api.infobip.com
. - These are not strictly required just for receiving webhooks.
-
Configure Inbound Message Routing (Webhook): The exact steps can vary slightly based on Infobip's UI updates, but generally involve:
- Navigate to the ""Numbers"" section or equivalent where you manage your phone numbers.
- Select the specific number you want to use for receiving SMS.
- Look for ""Messaging Settings"", ""Forwarding Rules"", ""Application Settings"", or similar configuration options for that number.
- Find the section for Incoming Messages or Mobile Originated (MO) Messages.
- Choose the option to forward messages to a URL (Webhook).
- Enter Your Webhook URL: This will be the publicly accessible URL of your deployed Next.js application, pointing to the API route. For example:
https://your-app-domain.com/api/infobip-webhook
. During local development, you'll use anngrok
URL (see Section 13 - Note: Section 13 was not provided in the original text, refer to testing sections). - Configure Authentication (Crucial for Security):
- Look for Authentication settings for the webhook.
- Select Basic Authentication.
- Enter the Username. Verify the required username in the Infobip portal. Common options might be your API Key ID or a specific string like
InfobipWebhook
. Enter what Infobip requires here. - Enter the Password. For the Password, enter the exact same secret you defined as
INFOBIP_WEBHOOK_SECRET
in your.env.local
file.
- Save the configuration for the number.
-
Environment Variables Summary:
INFOBIP_BASE_URL
(Optional for receiving): The base domain for API calls (e.g.,xxxxx.api.infobip.com
). Needed for sending.INFOBIP_API_KEY
(Optional for receiving): Your secret API key. Needed for sending.INFOBIP_WEBHOOK_SECRET
: The password you define and set in both.env.local
and the Infobip webhook Basic Auth configuration. Used by your webhook handler to verify requests. Required for secure receiving.DATABASE_URL
(Optional): Connection string for your database. Needed if storing messages.
5. Implementing Error Handling and Logging
Robust error handling and logging are vital for production applications.
-
Error Handling Strategy:
- The primary goal is to reliably acknowledge receipt to Infobip (200 OK) quickly after successful authentication.
- Use
try...catch
blocks to capture errors during request processing (parsing, validation, business logic). - Bad Requests (4xx): Return
400 Bad Request
for malformed JSON or invalid payload structures. Return401 Unauthorized
for failed authentication. Return405 Method Not Allowed
for incorrect HTTP methods. These indicate client-side issues (Infobip sending invalid data or incorrect configuration on either side). - Server Errors (Internal Processing): For errors occurring after successful authentication and basic validation (e.g., database connection unavailable, unexpected exceptions during your business logic), log the error thoroughly. Recommended: Return
200 OK
to acknowledge receipt to Infobip and prevent retries for potentially persistent internal issues. Log the error clearly so you can investigate. - Server Errors (Catastrophic): If a fundamental server issue prevents even basic request handling (rare in serverless environments but possible), a
500 Internal Server Error
might be unavoidable, potentially triggering Infobip retries. - Consistency: Apply this strategy consistently. Prioritize acknowledging receipt (200 OK) unless the request itself is invalid (4xx) or authentication fails (401).
-
Logging: While
console.log
works for development, use a structured logging library likepino
orwinston
for production.- Install Pino:
npm install pino pino-pretty # pino-pretty for development # or yarn add pino pino-pretty
- Setup Logger (Example): Create a utility file, e.g.,
src/lib/logger.ts
:// src/lib/logger.ts import pino from 'pino'; const logger = pino({ level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty', options: { colorize: true } } : undefined, // Use default stdout in production (or configure transports) }); export default logger;
- Use Logger in Webhook: (As demonstrated in Section 2 code)
- Use appropriate levels (
debug
,info
,warn
,error
). - Log key events: request received, auth success/failure, message processing start/end, errors encountered (with error objects).
- Avoid logging full message payloads at
info
level in production due to volume and potential PII; usedebug
if necessary.
- Use appropriate levels (
- Install Pino:
-
Retry Mechanisms (Infobip Side):
- Infobip typically retries webhook delivery if it doesn't receive a
2xx
response within a timeout period (e.g., a few seconds). - Your goal is to respond quickly (< 2-3 seconds) with
200 OK
after authentication and basic validation. - If processing is slow_ use asynchronous methods after sending the
200 OK
. - Returning
5xx
may trigger Infobip retries (check their docs for policy). Use this response code sparingly_ as recommended above.
- Infobip typically retries webhook delivery if it doesn't receive a
6. Creating a Database Schema and Data Layer (Optional)
Storing incoming messages enables history tracking_ analysis_ and stateful conversations. We'll use Prisma.
-
Install Prisma:
npm install prisma --save-dev npm install @prisma/client # or yarn add prisma -D && yarn add @prisma/client
-
Initialize Prisma:
npx prisma init --datasource-provider postgresql # Or mysql_ sqlite_ etc.
This creates a
prisma
directory with aschema.prisma
file and updates.env.local
with a placeholderDATABASE_URL
. Ensure your.env.local
has the correctDATABASE_URL
. -
Define Schema: Edit
prisma/schema.prisma
:// prisma/schema.prisma generator client { provider = ""prisma-client-js"" } datasource db { provider = ""postgresql"" // Or your chosen provider url = env(""DATABASE_URL"") } // Model to store incoming Infobip messages model IncomingMessage { id String @id @default(cuid()) // Unique ID for your record infobipMessageId String? @unique // Infobip's message ID_ helps prevent duplicates sender String // 'from' number recipient String // 'to' number (your Infobip number) messageText String? // The content of the SMS keyword String? // Detected keyword_ if any receivedAt DateTime // Timestamp when Infobip received it (store as UTC) processedAt DateTime @default(now()) // Timestamp when your app processed it createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([sender]) @@index([receivedAt]) }
-
Apply Schema Migrations:
- Create the first migration:
This creates SQL migration files and applies them to your database.
npx prisma migrate dev --name init
- Generate Prisma Client: This happens automatically after
migrate dev
_ but can be run manually:npx prisma generate
- Create the first migration:
-
Implement Data Access in Webhook:
- Create a Prisma client instance (e.g._
src/lib/prisma.ts
):// src/lib/prisma.ts import { PrismaClient } from '@prisma/client'; declare global { // allow global `var` declarations // eslint-disable-next-line no-var var prisma: PrismaClient | undefined; } export const prisma = global.prisma || new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['query'_ 'error'_ 'warn'] : ['error']_ }); if (process.env.NODE_ENV !== 'production') { global.prisma = prisma; } export default prisma;
- Use the client in your API route:
// src/app/api/infobip-webhook/route.ts import { NextRequest_ NextResponse } from 'next/server'; import logger from '@/lib/logger'; import prisma from '@/lib/prisma'; // Import Prisma client // Import Prisma types if needed for detailed error handling import { Prisma } from '@prisma/client'; // ... interfaces ... export async function POST(req: NextRequest) { // ... pre-processing_ authentication_ logging ... logger.info('Received request on /api/infobip-webhook'); try { // ... Authentication logic ... logger.info(`Webhook authentication successful.`); const body: InfobipWebhookPayload = await req.json(); // ... basic payload validation ... logger.debug({ payload: body }_ 'Webhook payload received'); if (body.results && body.results.length > 0) { // Process messages and attempt to store them try { // Use Prisma transaction for atomicity when saving multiple messages await prisma.$transaction(async (tx) => { for (const message of body.results) { logger.info({ from: message.from, msgId: message.messageId }, `Processing message for storage`); // Map Infobip payload to your schema const messageData = { infobipMessageId: message.messageId, sender: message.from ?? 'unknown', // Handle potential nulls recipient: message.to ?? 'unknown', messageText: message.text, keyword: message.keyword, // Ensure receivedAt is parsed correctly into a Date object receivedAt: message.receivedAt ? new Date(message.receivedAt) : new Date(), }; // Create record in the database await tx.incomingMessage.create({ data: messageData, }); logger.info({ msgId: message.messageId }, `Message stored successfully.`); // Add keyword handling or other logic here if needed } }); logger.info(`Successfully processed and stored ${body.results.length} message(s).`); } catch (dbError) { logger.error({ err: dbError }, ""Database transaction failed during message storage""); // Decide response based on strategy: // Option 1 (Recommended): Acknowledge receipt (200 OK) as auth succeeded, // but log the internal storage failure. Prevents retries for DB issues. return NextResponse.json({ message: 'Acknowledged, but failed to store message data.' }, { status: 200 }); // Option 2: Signal server error (500), potentially triggering Infobip retry. // Use if you believe a retry might succeed (e.g., transient DB issue). // return NextResponse.json({ error: 'Failed to store message data' }, { status: 500 }); } } else { logger.info('Received webhook with empty or invalid results array.'); return NextResponse.json({ message: 'Acknowledged empty/invalid payload' }, { status: 200 }); } // If storage (or other processing) succeeded return NextResponse.json({ message: 'Webhook received and processed successfully' }, { status: 200 }); } catch (error) { // Handle errors occurring before or during initial processing (auth, JSON parsing) if (error instanceof SyntaxError) { logger.error('Failed to parse incoming JSON payload.', { error }); return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 }); } // Check if it was an auth error re-thrown or similar logic needed // Example: if (error instanceof AuthError) { ... } // For simplicity, checking the status if it was set previously might work, // but robust error types are better. // Assuming auth errors resulted in a 401 being thrown/returned earlier: // if ((error as any)?.status === 401) { ... } - This is fragile. // Better to handle specific auth errors where they occur. logger.error('Unhandled error processing Infobip webhook', { error }); // Follow recommendation: Acknowledge receipt if possible, log internal error return NextResponse.json({ message: 'Acknowledged, but unexpected internal error occurred.' }, { status: 200 }); // Or return 500 for catastrophic failure: // return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } }
- Create a Prisma client instance (e.g._