code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

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:

  1. A Next.js application capable of sending SMS via Infobip.
  2. A database schema to track message status, including the unique Infobip messageId.
  3. An API endpoint (webhook handler) to securely receive delivery status updates from Infobip.
  4. Logic to update the message status in your database based on the callback data.
  5. 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:

  1. 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).
  2. Send SMS: A Next.js Server Action or API route uses the Infobip SDK and API credentials to send the SMS message.
  3. Store Initial Status: Upon successful submission to Infobip, the application records the message details (recipient, content, associated user ID) and the unique messageId returned by Infobip into the database, marking the initial status as ""PENDING"" or ""SENT"".
  4. Infobip Processing: Infobip attempts to deliver the SMS through carrier networks.
  5. 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.
  6. Webhook Handler: The Next.js API route at the callback URL receives the POST request.
  7. 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 its deliveryStatus, errorDescription (if any), and deliveredAt timestamp.

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:

bash
npx create-next-app@latest infobip-delivery-tracker --typescript --eslint --tailwind --src-dir --app --import-alias ""@/*""
cd infobip-delivery-tracker

Select 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.

bash
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 example
  • next-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).

bash
npx prisma init --datasource-provider postgresql

This 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.

dotenv
# .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 database infobip_tracker exists.
  • 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_USER and INFOBIP_WEBHOOK_PASSWORD for Basic Authentication or INFOBIP_WEBHOOK_SECRET for 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.

typescript
// 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
// 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 MessageStatus with fields to store Infobip's messageId, the recipient, message body, the evolving status, potential error details, and relevant timestamps.
  • infobipMessageId is marked @unique as 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 User model.
  • Added a comment placeholder for passwordHash on the User model if using the Credentials provider example.

2. Run Database Migration:

Apply these schema changes to your database.

bash
npx prisma migrate dev --name init

This 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.

typescript
// 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 PrismaAdapter to 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 bcrypt on user creation/update) and use a library like bcrypt.compare within the authorize function. The placeholder check is highly insecure and MUST be replaced.
  • Session Strategy: We explicitly set strategy: 'jwt'.
  • Callbacks: jwt and session callbacks are used to add the user.id to 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.

typescript
// 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.

typescript
// 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.

typescript
// 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.

typescript
// 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 from field.
  • messageId Extraction & DB Storage: Extracts the crucial messageId and stores the initial message details in the MessageStatus table.
  • Error Handling: Includes basic error handling.

3. Create Frontend Component (Example):

Create a form to trigger the Server Action.

typescript
// 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_USER and INFOBIP_WEBHOOK_PASSWORD in your .env.
      • Custom Header: Configure Infobip to send a custom header (e.g., X-Webhook-Secret) with the value of INFOBIP_WEBHOOK_SECRET from your .env.
    • Events: Ensure ""Delivery Reports"" (or similar SMS status events) are selected.
  • Save the configuration.

2. Create the Callback API Route:

typescript
// 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 .env variables.
  • Payload Parsing: Safely parses the incoming JSON.
  • Interface: Defines InfobipDeliveryReportPayload based on common fields (refer to official docs for specifics).
  • Batch Processing: Iterates through the results array, as Infobip can send multiple updates in one request.
  • Database Update: Finds the message by infobipMessageId and updates its status, error details, and deliveredAt timestamp. Uses toUpperCase() for status consistency.
  • Error Handling: Includes basic logging for authorization failures, parsing errors, missing messages, and database errors.
  • Response: Returns a 200 OK NextResponse 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.