code examples
code examples
Tracking SMS Delivery Status with Infobip Callbacks in Next.js and NextAuth
A guide on implementing Infobip delivery report webhooks in a Next.js application using NextAuth for authentication and Prisma for database persistence to track SMS delivery status.
Reliably sending SMS messages is crucial for many applications, but knowing if they were actually delivered is equally important. Relying solely on the initial ""sent"" status from an API isn't enough. Network issues, invalid numbers, or carrier blocks can prevent delivery.
This guide details how to build a robust system in Next.js using NextAuth for authentication and Infobip for SMS delivery. We'll implement Infobip's delivery report webhooks (callbacks) to receive real-time updates on message status directly within our application, persisting this information for tracking and potential automated actions.
By the end of this guide, you will have:
- A Next.js application capable of sending SMS via Infobip.
- A database schema to track message status, including the unique Infobip
messageId. - An API endpoint (webhook handler) to securely receive delivery status updates from Infobip.
- Logic to update the message status in your database based on the callback data.
- Integration with NextAuth for user context (optional but common).
Prerequisites:
- Node.js (v18 or later recommended)
- npm or yarn
- An Infobip account with API access
- A database (we'll use PostgreSQL with Prisma, but concepts apply elsewhere)
- Basic understanding of Next.js (App Router), React, API routes/server actions, and asynchronous JavaScript.
Technology Stack:
- Next.js: React framework for full-stack web applications.
- NextAuth.js (v4): Authentication library for Next.js. (Note: While this guide uses NextAuth.js v4, the current standard for new Next.js App Router projects is Auth.js (formerly NextAuth.js v5), which offers improved integration. You may consider adapting this guide for v5 or consult the official Auth.js documentation.)
- Infobip: Communications Platform as a Service (CPaaS) provider for SMS.
- Prisma: Next-generation ORM for Node.js and TypeScript.
- PostgreSQL: Relational database (can be swapped for SQLite, MySQL, etc.).
System Architecture
Here's a high-level overview of how the components interact:
- User Action: A logged-in user (authenticated via NextAuth) triggers an action in the Next.js app (e.g., clicking a ""Send Welcome SMS"" button).
- Send SMS: A Next.js Server Action or API route uses the Infobip SDK and API credentials to send the SMS message.
- Store Initial Status: Upon successful submission to Infobip, the application records the message details (recipient, content, associated user ID) and the unique
messageIdreturned by Infobip into the database, marking the initial status as ""PENDING"" or ""SENT"". - Infobip Processing: Infobip attempts to deliver the SMS through carrier networks.
- Delivery Report: Once the final delivery status is known (e.g., DELIVERED, UNDELIVERABLE, FAILED), Infobip sends an HTTP POST request (webhook) containing the status details to a predefined callback URL hosted by our Next.js application.
- Webhook Handler: The Next.js API route at the callback URL receives the POST request.
- Verify & Update: The handler verifies the request (optional but recommended security), parses the payload, finds the corresponding message record in the database using the
messageId, and updates itsdeliveryStatus,errorDescription(if any), anddeliveredAttimestamp.
Diagram Summary: The diagram illustrates the flow starting with a user action in the Next.js app, leading to an SMS send request via the Infobip API. The app records the initial message status. Infobip processes the SMS and sends a delivery report via webhook to a callback route in the Next.js app. This callback route verifies the request, finds the message in the database using the message ID, updates its status, and responds to Infobip.
1. Setting up the Project
Let's initialize a new Next.js project and install necessary dependencies.
1. Initialize Next.js Project:
Open your terminal and run:
npx create-next-app@latest infobip-delivery-tracker --typescript --eslint --tailwind --src-dir --app --import-alias ""@/*""
cd infobip-delivery-trackerSelect your preferred options when prompted. We're using the App Router (--app), TypeScript, ESLint, Tailwind CSS, the src/ directory, and import aliases.
2. Install Dependencies:
We need next-auth for authentication, Prisma for database interaction, and the Infobip SDK.
npm install next-auth @next-auth/prisma-adapter @prisma/client @infobip-api/sdk
npm install prisma --save-dev
npm install bcrypt @types/bcrypt # For password hashing examplenext-auth: Core library.@next-auth/prisma-adapter: Links NextAuth sessions/users to your Prisma database.@prisma/client: Prisma's database client.@infobip-api/sdk: Official Infobip Node.js SDK.prisma(dev dependency): Prisma CLI for migrations and studio.bcrypt,@types/bcrypt: For the Credentials provider password hashing example.
3. Initialize Prisma:
Set up Prisma with PostgreSQL (you can choose another database if preferred).
npx prisma init --datasource-provider postgresqlThis creates a prisma directory with a schema.prisma file and a .env file for your database connection string.
4. Configure Environment Variables:
Open the .env file created in the previous step. It will initially contain the DATABASE_URL. Add placeholders for Infobip credentials, NextAuth secrets, and webhook security options.
# .env
# Database
# Replace with your actual PostgreSQL connection string
# Example: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
DATABASE_URL=""postgresql://postgres:password@localhost:5432/infobip_tracker?schema=public""
# NextAuth
# Generate a strong secret: openssl rand -base64 32
NEXTAUTH_SECRET=""YOUR_REALLY_STRONG_NEXTAUTH_SECRET""
NEXTAUTH_URL=""http://localhost:3000"" # Your development URL
# Infobip
INFOBIP_API_KEY=""YOUR_INFOBIP_API_KEY""
INFOBIP_BASE_URL=""YOUR_INFOBIP_BASE_URL"" # e.g., xyz.api.infobip.com
# --- Webhook Security Options (Choose ONE method and configure in Infobip) ---
# Option 1: Basic Authentication (Recommended for simplicity)
# Set these if you configure Basic Auth in Infobip Webhook settings
INFOBIP_WEBHOOK_USER=""YOUR_INFOBIP_WEBHOOK_USERNAME""
INFOBIP_WEBHOOK_PASSWORD=""YOUR_INFOBIP_WEBHOOK_PASSWORD""
# Option 2: Secret Header Verification
# Set this if you configure a custom header (e.g., X-Webhook-Secret) in Infobip
# Generate another strong secret
INFOBIP_WEBHOOK_SECRET=""YOUR_REALLY_STRONG_WEBHOOK_SECRET""
# --- End Webhook Security Options ---DATABASE_URL: Update this with your actual database connection details. Ensure the databaseinfobip_trackerexists.NEXTAUTH_SECRET: Essential for securing NextAuth sessions/JWTs. Generate a strong random string.NEXTAUTH_URL: The canonical URL of your application. Crucial for redirects and callbacks.INFOBIP_API_KEY&INFOBIP_BASE_URL: Obtain these from your Infobip account dashboard (API Key Management). The Base URL is specific to your account.- Webhook Security Variables: Configure either
INFOBIP_WEBHOOK_USERandINFOBIP_WEBHOOK_PASSWORDfor Basic Authentication orINFOBIP_WEBHOOK_SECRETfor header-based verification, matching the security method you choose in the Infobip portal (see Section 5).
Important: Add .env to your .gitignore file to avoid committing secrets. Create a .env.example file listing the variables needed without their values.
5. Project Structure:
Your src/ directory will look something like this initially:
src/
├── app/
│ ├── api/ # API routes (including webhook handler)
│ │ └── auth/
│ │ └── [...nextauth]/
│ │ └── route.ts
│ │ └── infobip/
│ │ └── callback/
│ │ └── route.ts
│ ├── (auth)/ # Authentication related pages (login, etc. - optional grouping)
│ ├── layout.tsx
│ ├── page.tsx
│ └── globals.css
├── components/ # React components
│ └── AuthProvider.tsx
│ └── SendSmsForm.tsx
├── lib/ # Shared utilities, Prisma client instance
│ ├── prisma.ts # Prisma client singleton
│ └── infobip.ts # Infobip client instance
├── prisma/
│ ├── migrations/
│ └── schema.prisma
├── styles/ # If you have custom styles beyond globals
└── types/ # TypeScript types (e.g., for NextAuth session)
Create the lib directory and the prisma.ts file.
6. Initialize Prisma Client:
Create a singleton instance of the Prisma client to avoid creating too many connections in development.
// 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({
// Optional: log Prisma queries
// log: ['query', 'info', 'warn', 'error'],
});
if (process.env.NODE_ENV !== 'production') global.prisma = prisma;
export default prisma;2. Creating the Database Schema
Define the database models needed for users (via NextAuth adapter) and tracking message status.
1. Define Schema:
Update your prisma/schema.prisma file. Prisma's NextAuth adapter requires specific models (User, Account, Session, VerificationToken). We'll add our custom MessageStatus model.
// prisma/schema.prisma
generator client {
provider = ""prisma-client-js""
}
datasource db {
provider = ""postgresql"" // Or your chosen provider
url = env(""DATABASE_URL"")
}
// Required for NextAuth Prisma Adapter
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
// Add passwordHash if using Credentials provider with password
// passwordHash String?
accounts Account[]
sessions Session[]
messages MessageStatus[] // Relation to messages sent by this user
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// --- Our Custom Model ---
model MessageStatus {
id String @id @default(cuid())
infobipMessageId String @unique // The crucial ID from Infobip
userId String? // Optional: Link message to the user who sent it
recipient String // Phone number message was sent to
messageBody String @db.Text // Content of the message
status String // e.g., PENDING, SENT, DELIVERED_TO_HANDSET, FAILED_UNDELIVERABLE
errorCode String? // Infobip error code if status is failed/rejected
errorGroup String? // Infobip error group name
errorDescription String? // Infobip error description
sentAt DateTime @default(now()) // When we initiated the send request
statusUpdatedAt DateTime @updatedAt // When the status was last updated (by webhook)
deliveredAt DateTime? // Timestamp when Infobip reported final delivery
user User? @relation(fields: [userId], references: [id], onDelete: SetNull) // Optional relation
@@index([userId])
@@index([status])
@@index([sentAt])
@@index([infobipMessageId]) // Add index for faster lookups by messageId
}- We added
MessageStatuswith fields to store Infobip'smessageId, the recipient, message body, the evolvingstatus, potential error details, and relevant timestamps. infobipMessageIdis marked@uniqueas it's the primary identifier we'll use to match incoming webhooks. We also added an index@@index([infobipMessageId]).- We added an optional relation back to the
Usermodel. - Added a comment placeholder for
passwordHashon theUsermodel if using the Credentials provider example.
2. Run Database Migration:
Apply these schema changes to your database.
npx prisma migrate dev --name initThis command will:
- Create the SQL migration file in
prisma/migrations/. - Apply the migration to your database, creating the tables.
- Generate/update the Prisma Client based on the schema.
You can inspect your database structure using npx prisma studio if desired.
3. Implementing NextAuth
Configure NextAuth to handle user sessions. We'll use JWT sessions and add the userId to the token/session object so we can associate sent messages with users.
1. Create NextAuth API Route:
Create the catch-all API route handler for NextAuth.
// src/app/api/auth/[...nextauth]/route.ts
import NextAuth, { type NextAuthOptions } from 'next-auth';
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import prisma from '@/lib/prisma';
// Import bcrypt for password hashing (or your preferred library)
import bcrypt from 'bcrypt';
// Import your chosen providers (e.g., Credentials, Google, etc.)
// Example using Credentials provider (replace with your actual setup)
import CredentialsProvider from 'next-auth/providers/credentials';
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
// Add your authentication providers here
// Example: Email provider (ensure you configure email sending)
// EmailProvider({ server: process.env.EMAIL_SERVER, from: process.env.EMAIL_FROM }),
// Example: Credentials Provider (implement authorize function carefully)
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email', placeholder: 'user@example.com' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials, req) {
// --- !!! WARNING: Implement secure password hashing and user lookup here !!! ---
// This example requires a 'passwordHash' field on your User model in schema.prisma.
// You MUST hash passwords securely on registration/update.
if (!credentials?.email || !credentials.password) {
console.error('Credentials missing');
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email },
// Include the password hash field if you added one to your User model
// select: { id: true, name: true, email: true, image: true, passwordHash: true }
});
// Check if user exists and has a password hash stored
// if (!user || !user.passwordHash) {
// console.error('No user found or password not set for email:', credentials.email);
// return null; // User not found or password not set up
// }
// --- Securely compare the provided password with the stored hash ---
// Example assumes you have a 'passwordHash' field on your User model
// const passwordMatch = await bcrypt.compare(credentials.password, user.passwordHash);
// !!! Placeholder Check - Replace with actual bcrypt.compare !!!
// Remove this placeholder and uncomment the bcrypt logic above after setting up password hashing
const passwordMatch = user ? true : false; // !!! Insecure Placeholder !!!
if (passwordMatch && user) {
// Passwords match - return the user object (excluding the password hash)
console.log('Credentials verified for:', user.email);
return { id: user.id, name: user.name, email: user.email, image: user.image };
} else {
// Passwords do not match or user not found
console.error('Password mismatch or user not found for email:', credentials.email);
return null; // Returning null triggers an error flow
}
},
}),
// Add other providers like Google, GitHub, etc. as needed
],
session: {
strategy: 'jwt', // Use JSON Web Tokens for session management
},
callbacks: {
// Include user.id on session JWT and session object
async jwt({ token, user }) {
// Persist the user ID from the User model onto the JWT
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
// Send properties to the client (like user.id)
// The token parameter here is the JWT payload from the jwt callback
if (token?.id && session.user) {
session.user.id = token.id as string; // Add id to user object on session
}
// Add other properties like roles if needed
return session;
},
// Optional: Redirect callback customization (Default is usually fine)
// async redirect({ url, baseUrl }) { ... }
// Optional: Sign in callback for access control
// async signIn({ user, account, profile, email, credentials }) { ... }
},
pages: {
signIn: '/login', // Optional: path to your custom login page
// error: '/auth/error', // Optional: path to custom error page
},
// Ensure you have NEXTAUTH_SECRET in your .env
secret: process.env.NEXTAUTH_SECRET,
// debug: process.env.NODE_ENV === 'development', // Optional: Enable debug logs
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };- Adapter: We configure the
PrismaAdapterto store user/session data in our database. - Providers: Add your chosen authentication methods. The Credentials provider example requires secure implementation: you must store hashed passwords (e.g., using
bcrypton user creation/update) and use a library likebcrypt.comparewithin theauthorizefunction. The placeholder check is highly insecure and MUST be replaced. - Session Strategy: We explicitly set
strategy: 'jwt'. - Callbacks:
jwtandsessioncallbacks are used to add theuser.idto the session object, making it available in your application. - Pages: Define custom login/error pages if needed.
- Secret: Reads from
.env.
2. Create Auth Provider Component:
Wrap your application layout with the NextAuth SessionProvider.
// src/components/AuthProvider.tsx
'use client';
import { SessionProvider } from 'next-auth/react';
import React from 'react';
interface AuthProviderProps {
children: React.ReactNode;
}
export default function AuthProvider({ children }: AuthProviderProps) {
return <SessionProvider>{children}</SessionProvider>;
}3. Update Root Layout:
Apply the AuthProvider in your root layout.
// src/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import AuthProvider from '@/components/AuthProvider'; // Adjust path if needed
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Infobip Delivery Tracker',
description: 'Track Infobip SMS delivery status',
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang=""en"">
<body className={inter.className}>
<AuthProvider> {/* Wrap content with AuthProvider */}
{children}
</AuthProvider>
</body>
</html>
);
}Now you should have a basic authentication setup ready. Create a login page (src/app/login/page.tsx or similar) and implement login/logout UI elements using signIn() and signOut() from next-auth/react.
4. Sending SMS and Storing Initial Status
Let's create a mechanism (e.g., a Server Action) to send an SMS via Infobip and record its initial status.
1. Create Infobip Client Utility:
Centralize the Infobip client initialization.
// src/lib/infobip.ts
import { Infobip, AuthType } from '@infobip-api/sdk';
if (!process.env.INFOBIP_API_KEY || !process.env.INFOBIP_BASE_URL) {
throw new Error('Infobip API Key or Base URL not configured in .env');
}
const infobip = new Infobip({
baseUrl: process.env.INFOBIP_BASE_URL,
apiKey: process.env.INFOBIP_API_KEY,
authType: AuthType.ApiKey,
});
export default infobip;2. Create Server Action:
Use a Server Action to handle sending SMS.
// src/app/actions/smsActions.ts
'use server'; // Mark this module as containing Server Actions
import { z } from 'zod';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/app/api/auth/[...nextauth]/route'; // Adjust path
import infobip from '@/lib/infobip';
import prisma from '@/lib/prisma';
import { revalidatePath } from 'next/cache'; // To potentially refresh UI displaying messages
// Define input schema using Zod for validation
const SendSmsSchema = z.object({
recipient: z.string().min(10, { message: 'Valid phone number required' }), // Basic validation
messageBody: z.string().min(1, { message: 'Message cannot be empty' }).max(160),
});
export interface SendSmsFormState {
message: string | null;
success: boolean;
errors?: {
recipient?: string[];
messageBody?: string[];
server?: string;
};
}
export async function sendSmsAction(
prevState: SendSmsFormState, // Required for useFormState
formData: FormData
): Promise<SendSmsFormState> {
// 1. Get current session
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return { success: false, message: 'Authentication required.', errors: { server: 'Authentication required.' } };
}
const userId = session.user.id;
// 2. Validate form data
const validatedFields = SendSmsSchema.safeParse({
recipient: formData.get('recipient'),
messageBody: formData.get('messageBody'),
});
if (!validatedFields.success) {
return {
success: false,
message: 'Validation failed.',
errors: validatedFields.error.flatten().fieldErrors,
};
}
const { recipient, messageBody } = validatedFields.data;
try {
// 3. Send SMS via Infobip
console.log(`Sending SMS to ${recipient} by user ${userId}`);
const response = await infobip.channels.sms.send({
messages: [
{
// from: ""YourSenderID"", // Optional: Using a specific Sender ID might require pre-registration with Infobip
// and depends on destination country regulations. Check Infobip docs if needed.
destinations: [{ to: recipient }],
text: messageBody,
// Optional: notifyUrl: process.env.NEXTAUTH_URL + '/api/infobip/callback'
// You can specify the callback URL per message, but configuring it globally in Infobip is usually preferred.
},
],
});
const messageResult = response.data.messages?.[0];
if (!messageResult || !messageResult.messageId) {
console.error('Infobip response missing messageId:', response.data);
return { success: false, message: 'Failed to send SMS: Invalid response from provider.', errors: { server: 'Invalid response from provider.' } };
}
const infobipMessageId = messageResult.messageId;
const initialStatus = messageResult.status?.groupName ?? 'PENDING'; // Use status from response if available
console.log(`SMS submitted to Infobip. Message ID: ${infobipMessageId}, Initial Status: ${initialStatus}`);
// 4. Store initial status in Database
await prisma.messageStatus.create({
data: {
infobipMessageId: infobipMessageId,
userId: userId,
recipient: recipient,
messageBody: messageBody,
status: initialStatus.toUpperCase(), // Normalize status
// Error fields will be populated by the callback if needed
},
});
// 5. Optionally revalidate paths if you have a page displaying messages
// revalidatePath('/dashboard/messages');
return { success: true, message: `SMS submitted successfully! Message ID: ${infobipMessageId}` };
} catch (error: any) {
console.error('Error sending SMS:', error);
// Check for Infobip specific errors if possible
let errorMessage = 'An unexpected error occurred.';
if (error.response?.data?.requestError?.serviceException?.text) {
errorMessage = `Infobip Error: ${error.response.data.requestError.serviceException.text}`;
} else if (error instanceof Error) {
errorMessage = error.message;
}
return { success: false, message: errorMessage, errors: { server: errorMessage } };
}
}'use server': Marks this as a Server Action module.- Authentication & Validation: Checks session and validates input using
zod. - Infobip SDK: Calls
infobip.channels.sms.send. - Sender ID Comment: Clarified the purpose and requirements for the optional
fromfield. messageIdExtraction & DB Storage: Extracts the crucialmessageIdand stores the initial message details in theMessageStatustable.- Error Handling: Includes basic error handling.
3. Create Frontend Component (Example):
Create a form to trigger the Server Action.
// src/components/SendSmsForm.tsx
'use client';
import React from 'react';
import { useFormState, useFormStatus } from 'react-dom';
import { sendSmsAction, SendSmsFormState } from '@/app/actions/smsActions'; // Adjust path
const initialState: SendSmsFormState = {
message: null,
success: false,
};
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type=""submit""
aria-disabled={pending}
disabled={pending}
className=""px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50""
>
{pending ? 'Sending...' : 'Send SMS'}
</button>
);
}
export default function SendSmsForm() {
const [state, formAction] = useFormState(sendSmsAction, initialState);
return (
<form action={formAction} className=""space-y-4 p-4 border rounded max-w-md mx-auto"">
<h2 className=""text-xl font-semibold mb-4"">Send New SMS</h2>
<div>
<label htmlFor=""recipient"" className=""block text-sm font-medium text-gray-700"">
Recipient Phone Number
</label>
<input
type=""tel""
id=""recipient""
name=""recipient""
required
className=""mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm""
placeholder=""+12345678900""
/>
{state.errors?.recipient && (
<p className=""mt-1 text-sm text-red-600"">{state.errors.recipient.join(', ')}</p>
)}
</div>
<div>
<label htmlFor=""messageBody"" className=""block text-sm font-medium text-gray-700"">
Message
</label>
<textarea
id=""messageBody""
name=""messageBody""
rows={3}
required
maxLength={160}
className=""mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm""
placeholder=""Enter your message...""
></textarea>
{state.errors?.messageBody && (
<p className=""mt-1 text-sm text-red-600"">{state.errors.messageBody.join(', ')}</p>
)}
</div>
<SubmitButton />
{state.message && (
<p className={`mt-2 text-sm ${state.success ? 'text-green-600' : 'text-red-600'}`}>
{state.message}
</p>
)}
{state.errors?.server && (
<p className=""mt-2 text-sm text-red-600"">
Server Error: {state.errors.server}
</p>
)}
</form>
);
}Add <SendSmsForm /> to an authenticated page.
5. Implementing the Infobip Callback Handler
This route receives status updates from Infobip.
1. Configure Infobip Webhook:
- Log in to your Infobip account.
- Navigate to Apps -> Create App (or select an existing app).
- Find the section for Delivery Reports or Webhooks.
- Add a new webhook configuration:
- URL: Enter the full URL of your callback handler. For local development, use
ngrok(e.g.,https://YOUR_NGROK_SUBDOMAIN.ngrok.io/api/infobip/callback). For production:https://yourdomain.com/api/infobip/callback. - Security (Recommended): Choose ONE method:
- Basic Authentication: Set a username and password here. Ensure these match
INFOBIP_WEBHOOK_USERandINFOBIP_WEBHOOK_PASSWORDin your.env. - Custom Header: Configure Infobip to send a custom header (e.g.,
X-Webhook-Secret) with the value ofINFOBIP_WEBHOOK_SECRETfrom your.env.
- Basic Authentication: Set a username and password here. Ensure these match
- Events: Ensure ""Delivery Reports"" (or similar SMS status events) are selected.
- URL: Enter the full URL of your callback handler. For local development, use
- Save the configuration.
2. Create the Callback API Route:
// src/app/api/infobip/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
// --- Security Functions (Implement ONE based on your .env and Infobip config) ---
// Method 1: Basic Authentication Verification
async function verifyBasicAuth(req: NextRequest): Promise<boolean> {
const authHeader = req.headers.get('authorization');
const expectedUsername = process.env.INFOBIP_WEBHOOK_USER;
const expectedPassword = process.env.INFOBIP_WEBHOOK_PASSWORD;
if (!expectedUsername || !expectedPassword) {
console.error(""Webhook Basic Auth credentials not set in environment variables. Denying request."");
return false; // Cannot verify if credentials aren't set
}
if (!authHeader) {
console.warn('Webhook received without Authorization header');
return false;
}
try {
// Ensure header format is 'Basic base64encoded'
const base64Credentials = authHeader.split(' ')[1];
if (!base64Credentials) {
console.warn('Invalid Basic Auth header format');
return false;
}
const auth = Buffer.from(base64Credentials, 'base64').toString().split(':');
const username = auth[0];
const password = auth[1];
const isValid = username === expectedUsername && password === expectedPassword;
if (!isValid) {
console.warn('Webhook received with invalid Basic Auth credentials');
}
return isValid;
} catch (error) {
console.error(""Error parsing Basic Auth header:"", error);
return false;
}
}
// Method 2: Secret Header Verification
async function verifySecretHeader(req: NextRequest): Promise<boolean> {
// Ensure this header name matches what you configured in Infobip
const headerName = 'X-Webhook-Secret'; // Case-insensitive lookup is handled by `req.headers.get`
const receivedSecret = req.headers.get(headerName);
const expectedSecret = process.env.INFOBIP_WEBHOOK_SECRET;
if (!expectedSecret) {
console.error(""INFOBIP_WEBHOOK_SECRET not set in environment variables. Denying request."");
return false; // Fail securely if secret isn't configured
}
if (!receivedSecret) {
console.warn(`Webhook received without ${headerName} header`);
return false;
}
// Perform constant-time comparison if security is critical, though for random secrets it's less vital
if (receivedSecret !== expectedSecret) {
console.warn('Webhook received with invalid secret header value');
return false;
}
return true;
}
// --- End Security Functions ---
/**
* Represents the structure of the delivery report payload from Infobip.
* Note: This structure is based on documentation at the time of writing.
* Always consult the official Infobip API documentation for the most
* current and complete payload details, as these can change.
* Example: https://www.infobip.com/docs/api/channels/sms/sms-messaging/webhook-reports/delivery-reports
*/
interface InfobipDeliveryReportPayload {
results: Array<{
bulkId?: string;
messageId: string;
to: string;
sentAt?: string; // ISO 8601
doneAt?: string; // ISO 8601 timestamp when final status was determined
smsCount?: number;
price?: {
pricePerMessage?: number;
currency?: string;
};
status: {
groupId: number;
groupName: string; // e.g., ""PENDING"", ""UNDELIVERABLE"", ""DELIVERED"", ""EXPIRED"", ""REJECTED""
id: number;
name: string; // e.g., ""MESSAGE_WAITING"", ""UNDELIVERABLE_NOT_DELIVERED"", ""DELIVERED_TO_HANDSET"", ""EXPIRED_UNKNOWN"", ""REJECTED_NETWORK""
description: string;
};
error?: { // Present on failures/rejections
groupId: number;
groupName: string; // e.g., ""HANDSET_ERRORS"", ""NETWORK_ERRORS"", ""OPERATOR_ERRORS""
id: number;
name: string; // e.g., ""EC_ABSENT_SUBSCRIBER"", ""EC_NETWORK_ERROR"", ""EC_ILLEGAL_EQUIPMENT""
description: string;
permanent: boolean;
};
callbackData?: string; // If you included it in the send request
networkId?: string;
entityId?: string; // If applicable
applicationId?: string; // If applicable
}>;
messageCount?: number;
pendingMessageCount?: number;
}
export async function POST(req: NextRequest) {
console.log('Received Infobip webhook callback');
// --- 1. Security Verification ---
// Choose ONE method based on your configuration:
const isAuthorized = await verifyBasicAuth(req); // Use this if using Basic Auth
// const isAuthorized = await verifySecretHeader(req); // Use this if using Secret Header
if (!isAuthorized) {
console.warn('Webhook authorization failed.');
// Return 401 Unauthorized or 403 Forbidden
return new NextResponse('Unauthorized', { status: 401 });
}
console.log('Webhook authorization successful.');
// --- 2. Parse Request Body ---
let payload: InfobipDeliveryReportPayload;
try {
payload = await req.json();
if (!payload || !Array.isArray(payload.results) || payload.results.length === 0) {
console.warn('Webhook received empty or invalid payload format.');
return new NextResponse('Bad Request: Invalid payload', { status: 400 });
}
} catch (error) {
console.error('Error parsing webhook JSON payload:', error);
return new NextResponse('Bad Request: Could not parse JSON', { status: 400 });
}
// --- 3. Process Each Result ---
// Infobip often sends results in batches
for (const result of payload.results) {
if (!result.messageId) {
console.warn('Skipping result with missing messageId:', result);
continue;
}
const { messageId, status, error, doneAt } = result;
try {
console.log(`Processing status update for messageId: ${messageId}`);
// Find the corresponding message in the database
const existingMessage = await prisma.messageStatus.findUnique({
where: { infobipMessageId: messageId },
});
if (!existingMessage) {
console.warn(`Message with infobipMessageId ${messageId} not found in database. Ignoring update.`);
continue; // Or handle as an error if this shouldn't happen
}
// Update the message status record
await prisma.messageStatus.update({
where: { id: existingMessage.id },
data: {
status: status.groupName.toUpperCase(), // Normalize status (e.g., DELIVERED, FAILED)
errorCode: error?.name, // e.g., EC_ABSENT_SUBSCRIBER_PERMANENT
errorGroup: error?.groupName,
errorDescription: error?.description,
deliveredAt: doneAt ? new Date(doneAt) : null, // Convert ISO string to Date
// statusUpdatedAt is handled automatically by @updatedAt
},
});
console.log(`Successfully updated status for messageId ${messageId} to ${status.groupName}`);
// Optional: Trigger further actions based on status
// if (status.groupName === 'DELIVERED') { /* ... */ }
// if (error) { /* Log error, notify admin, etc. */ }
} catch (dbError) {
console.error(`Database error updating status for messageId ${messageId}:`, dbError);
// Decide how to handle DB errors - retry? log? alert?
// Continuing loop to process other results in the batch
}
}
// --- 4. Respond to Infobip ---
// Acknowledge receipt with a 200 OK. Infobip expects this to stop retries.
console.log('Webhook processing complete. Sending 200 OK.');
return new NextResponse('Callback received successfully', { status: 200 });
}- Security: Includes functions for Basic Auth and Secret Header verification. Uncomment and use only the one that matches your Infobip configuration and
.envvariables. - Payload Parsing: Safely parses the incoming JSON.
- Interface: Defines
InfobipDeliveryReportPayloadbased on common fields (refer to official docs for specifics). - Batch Processing: Iterates through the
resultsarray, as Infobip can send multiple updates in one request. - Database Update: Finds the message by
infobipMessageIdand updates its status, error details, anddeliveredAttimestamp. UsestoUpperCase()for status consistency. - Error Handling: Includes basic logging for authorization failures, parsing errors, missing messages, and database errors.
- Response: Returns a
200 OKNextResponse to acknowledge receipt to Infobip.
With these components in place, your Next.js application can now send SMS messages via Infobip and reliably track their final delivery status using webhooks. Remember to deploy your application and update the Infobip webhook URL to your production endpoint. Use ngrok or a similar tool for testing callbacks during local development.
Frequently Asked Questions
How to track Infobip SMS delivery in Next.js?
Implement Infobip's delivery report webhooks to receive real-time status updates within your Next.js application. This involves setting up a callback URL in your app that Infobip will send POST requests to, containing delivery status details like 'DELIVERED', 'UNDELIVERABLE', or 'FAILED'.
What is an Infobip delivery report webhook?
An Infobip delivery report webhook (or callback) is an HTTP POST request sent by Infobip to your application. It contains real-time updates on the delivery status of your SMS messages, providing information beyond the initial 'sent' status from the API.
Why use Infobip callbacks for SMS tracking?
Infobip callbacks provide more reliable delivery confirmation than simply relying on the initial 'sent' status. Network issues or invalid numbers can prevent delivery, and callbacks offer real-time updates on these situations, enabling you to take appropriate action.
When should I set up Infobip delivery report webhooks?
Set up Infobip delivery report webhooks when you need reliable tracking of SMS message delivery. This is particularly important for critical messages like two-factor authentication, appointment reminders, or order confirmations where knowing the delivery outcome is essential.
Can I use a different database with this Infobip guide?
Yes, while the guide uses PostgreSQL with Prisma, the core concepts can be applied to other databases. The key is to have a database schema that can store message status, including the Infobip `messageId` for matching incoming webhook data.
How to send SMS with Infobip in a Next.js app?
Use the Infobip Node.js SDK within a Next.js Server Action or API route, along with your API key and base URL, to send SMS messages. Make sure to store the `messageId` returned by Infobip for later tracking with the webhooks.
What is the purpose of the Infobip `messageId`?
The Infobip `messageId` is a unique identifier for each SMS message you send. It's crucial for correlating delivery reports received via webhooks with the specific message they refer to in your database.
How to secure Infobip webhook callbacks in Next.js?
Secure your webhook callback route using either Basic Authentication or custom header verification. Configure one of these methods in the Infobip portal and implement corresponding verification logic in your Next.js API route using environment variables for security credentials.
What Next.js version is compatible with this Infobip integration?
The article recommends using the latest version of Next.js with the App Router, which is the current standard. While the code examples use NextAuth.js v4, you might consider Auth.js (formerly NextAuth.js v5) for new App Router projects due to better integration.
How to handle batch delivery reports from Infobip?
Infobip webhooks can deliver status updates for multiple messages in a single request. Your callback handler should iterate through the 'results' array in the payload and process each message status update individually.
What are the prerequisites for implementing this Infobip SMS tracking?
You'll need Node.js v18 or later, npm or yarn, an Infobip account with API access, a database (PostgreSQL, SQLite, MySQL, or similar), and a basic understanding of Next.js, React, API routes, and asynchronous JavaScript.
Why does the guide recommend using NextAuth for Infobip integration?
NextAuth simplifies user authentication in Next.js. Integrating it allows you to easily associate sent SMS messages with specific users in your application, which is often a common requirement.
What are common Infobip SMS delivery status values?
Common status values include 'PENDING', 'SENT', 'DELIVERED_TO_HANDSET', 'FAILED_UNDELIVERABLE', 'EXPIRED', and 'REJECTED'. The exact values and their meanings are available in the official Infobip documentation.