This guide provides a complete walkthrough for building a Next.js application capable of sending bulk or broadcast messages using the Sinch Conversation API. We'll cover everything from project setup and core implementation to security, error handling, and deployment.
This implementation enables developers to leverage Sinch's robust communication infrastructure for sending notifications, alerts, or marketing messages to multiple recipients efficiently directly from a Next.js application. By the end, you'll have a functional API endpoint and a basic interface to trigger bulk message sends.
Project Overview and Goals
What We're Building:
- A Next.js application with a serverless API route (
/api/send-bulk
). - A simple frontend form to input a list of recipient phone numbers and a message body.
- Backend logic to interact with the Sinch Conversation API to send messages individually to each recipient in the list (simulating a bulk send).
- Secure handling of API credentials.
- Error handling, logging, and a basic retry mechanism.
Problem Solved: This guide addresses the need to programmatically send the same message content to a list of recipients via SMS (or other channels supported by the Conversation API) using Sinch, integrated within a modern web framework like Next.js.
Technologies Used:
- Next.js: A React framework for building server-side rendering (SSR) and static web applications, including API routes for backend logic. Chosen for its developer experience, performance, and integrated API capabilities.
- Sinch Conversation API: A unified API for sending and receiving messages across various channels (SMS, WhatsApp, etc.). Chosen for its channel flexibility and robust infrastructure.
- Node.js: The underlying runtime for Next.js.
- Axios: For making HTTP requests to the Sinch API.
- jsonwebtoken: For generating JWTs (potentially needed for Sinch authentication, verification required).
- libphonenumber-js: For phone number validation and formatting.
- (Optional) Prisma & PostgreSQL: For persistent storage of contacts or message logs (discussed conceptually).
System Architecture:
User Browser --(1. Submits Form)--> Next.js Frontend
Next.js Frontend --(2. POST Request)--> Next.js API Route (/api/send-bulk)
Next.js API Route --(3. Reads Credentials)--> Environment Variables (.env.local)
Next.js API Route --(4. Loops through Recipients)--> For Each Recipient
For Each Recipient --(5. Prepare & Send Request w/ Retries)--> Sinch Conversation API
Sinch Conversation API --(6. Send Message)--> Recipient Phone
Sinch Conversation API --(7. API Response)--> For Each Recipient
For Each Recipient --(8. Aggregated Results)--> Next.js API Route
Next.js API Route --(9. API Response)--> Next.js Frontend
Next.js Frontend --(10. Display Status)--> User Browser
Prerequisites:
- Node.js (v18 or later recommended) and npm/yarn/pnpm.
- A Sinch account with access to the Conversation API.
- A registered Sinch Application (
APP_ID
) within your Sinch project. - Sinch API Credentials:
SINCH_PROJECT_ID
: Your project's unique identifier.SINCH_ACCESS_KEY_ID
: The Key ID for your API access key pair.SINCH_ACCESS_KEY
: The Key Secret for your API access key pair (treat like a password).
- A provisioned phone number or sender ID from Sinch capable of sending messages, linked to your
APP_ID
.
Expected Outcome: A Next.js application where users can input a comma-separated list of phone numbers, type a message, and click 'Send'. The application's backend will then iterate through the numbers and attempt to send the message to each via the Sinch Conversation API, providing feedback on success or failure.
1. Setting up the Project
Let's initialize a new Next.js project and configure the necessary environment.
1.1 Create Next.js App:
Open your terminal and run the following command:
npx create-next-app@latest sinch-bulk-messaging --typescript --eslint --tailwind --src-dir --app --import-alias "@/*"
sinch-bulk-messaging
: Your project name.--typescript
: Enables TypeScript (recommended).--eslint
: Includes ESLint for code linting.--tailwind
: Includes Tailwind CSS for styling (optional).--src-dir
: Creates asrc
directory for code.--app
: Uses the App Router (standard for new projects).--import-alias "@/*"
: Configures path aliases.
Navigate into your new project directory:
cd sinch-bulk-messaging
1.2 Install Dependencies:
We'll use axios
for API requests, jsonwebtoken
for potential token generation, and libphonenumber-js
for validation.
npm install axios jsonwebtoken libphonenumber-js
npm install --save-dev @types/jsonwebtoken
# or using yarn
# yarn add axios jsonwebtoken libphonenumber-js
# yarn add --dev @types/jsonwebtoken
# or using pnpm
# pnpm add axios jsonwebtoken libphonenumber-js
# pnpm add --save-dev @types/jsonwebtoken
1.3 Configure Environment Variables:
Create a file named .env.local
in the root of your project. Never commit this file to version control.
# .env.local
# Sinch API Credentials (Obtain from your Sinch Dashboard)
SINCH_PROJECT_ID="YOUR_SINCH_PROJECT_ID"
SINCH_ACCESS_KEY_ID="YOUR_SINCH_ACCESS_KEY_ID" # The Key ID
SINCH_ACCESS_KEY="YOUR_SINCH_ACCESS_KEY_SECRET" # The Key Secret
SINCH_APP_ID="YOUR_REGISTERED_SINCH_APP_ID"
# Sender ID (e.g., your provisioned Sinch phone number in E.164 format)
# This MUST be associated with your SINCH_APP_ID
SINCH_SENDER_ID="YOUR_SINCH_PHONE_NUMBER_OR_SENDER_ID"
# Sinch API Region Base URL (e.g., eu, us, etc.)
# Check Sinch documentation for the correct region for your account
SINCH_API_BASE_URL="https://eu.conversation.api.sinch.com" # Or other region like us.conversation.api.sinch.com
# A secret used ONLY if generating *internal* application JWTs (e.g., for sessions), NOT for Sinch auth.
# Generate a strong random string (e.g., using openssl rand -hex 32)
JWT_SECRET="generate-a-strong-random-secret-here-for-internal-use"
- Why
.env.local
? Next.js automatically loads variables from this file intoprocess.env
on the server side. It's designated for secret keys and should be listed in your.gitignore
file (whichcreate-next-app
does by default). - Obtaining Credentials: Log in to your Sinch account/portal. Navigate to your Project settings and API access keys section to find your
SINCH_PROJECT_ID
and create anACCESS_KEY
(consisting of a Key ID (SINCH_ACCESS_KEY_ID
) and a Key Secret (SINCH_ACCESS_KEY
)). Find your Application (SINCH_APP_ID
) under the Conversation API section or Apps. YourSINCH_SENDER_ID
is the phone number or identifier you've configured within Sinch to send messages from. Ensure this sender is linked to yourSINCH_APP_ID
.
1.4 Project Structure:
Your src
directory will primarily contain:
app/
: App Router files (pages, layouts, API routes).page.tsx
: The main frontend page with the form.api/send-bulk/route.ts
: The API route handler for sending messages.
lib/
: Utility functions (e.g., Sinch API interaction logic).
This structure separates frontend, backend (API routes), and reusable logic.
2. Implementing Core Functionality
We'll build the frontend form and the backend API route logic.
2.1 Frontend Form (src/app/page.tsx
):
Replace the contents of src/app/page.tsx
with a simple form:
// src/app/page.tsx
'use client'; // Required for interactive components in App Router
import { useState } from 'react';
import axios from 'axios';
export default function HomePage() {
const [recipients, setRecipients] = useState('');
const [message, setMessage] = useState('');
const [status, setStatus] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsLoading(true);
setStatus('Sending messages...');
// Basic validation
if (!recipients.trim() || !message.trim()) {
setStatus('Please enter recipients and a message.');
setIsLoading(false);
return;
}
// Split recipients string into an array, trim whitespace, filter empty strings
const recipientList = recipients
.split(',')
.map(num => num.trim())
.filter(num => num.length > 0);
if (recipientList.length === 0) {
setStatus('Invalid recipient list.');
setIsLoading(false);
return;
}
try {
const response = await axios.post('/api/send-bulk', {
recipients: recipientList,
message,
});
if (response.data.success) {
setStatus(`Successfully initiated sending to ${response.data.sentCount} recipients. Failures: ${response.data.failedCount}.`);
// Optionally display failure details if needed: response.data.failures
} else {
setStatus(`Error: ${response.data.error || 'Failed to send messages.'}`);
}
} catch (error: any) {
console.error('Error sending bulk message:', error);
const errorMessage = error.response?.data?.error || error.message || 'An unexpected error occurred.';
setStatus(`Error: ${errorMessage}`);
} finally {
setIsLoading(false);
}
};
return (
<main className=""flex min-h-screen flex-col items-center justify-center p-12 bg-gray-50"">
<div className=""w-full max-w-md p-8 space-y-6 bg-white rounded-lg shadow-md"">
<h1 className=""text-2xl font-bold text-center text-gray-800"">Sinch Bulk Message Sender</h1>
<form onSubmit={handleSubmit} className=""space-y-4"">
<div>
<label htmlFor=""recipients"" className=""block text-sm font-medium text-gray-700"">
Recipients (comma-separated phone numbers, e.g., +15551234567,+447700900000)
</label>
<textarea
id=""recipients""
name=""recipients""
rows={3}
value={recipients}
onChange={(e) => setRecipients(e.target.value)}
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=""+15551234567,+447700900000""
/>
</div>
<div>
<label htmlFor=""message"" className=""block text-sm font-medium text-gray-700"">
Message
</label>
<textarea
id=""message""
name=""message""
rows={4}
value={message}
onChange={(e) => setMessage(e.target.value)}
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=""Enter your message here...""
/>
</div>
<div>
<button
type=""submit""
disabled={isLoading}
className={`w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white ${
isLoading ? 'bg-indigo-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500'
}`}
>
{isLoading ? 'Sending...' : 'Send Bulk Message'}
</button>
</div>
</form>
{status && (
<div className={`mt-4 p-3 rounded-md text-sm ${status.startsWith('Error:') ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>
{status}
</div>
)}
</div>
</main>
);
}
'use client'
: Marks this as a Client Component, necessary for using hooks likeuseState
and handling events.- State: Manages input values (
recipients
,message
), loading state (isLoading
), and status messages (status
). handleSubmit
: Prevents default form submission, performs basic validation, formats the recipient list, calls the/api/send-bulk
endpoint, and updates the status based on the response.
2.2 Sinch API Utility (src/lib/sinch.ts
):
Create a utility file to encapsulate Sinch API interactions.
// src/lib/sinch.ts
import axios, { AxiosInstance } from 'axios';
import * as jwt from 'jsonwebtoken'; // For potential JWT-based auth
// --- Environment Variable Validation ---
const projectId = process.env.SINCH_PROJECT_ID;
const accessKeyId = process.env.SINCH_ACCESS_KEY_ID; // Key ID from Sinch portal
const accessKeySecret = process.env.SINCH_ACCESS_KEY; // Key Secret from Sinch portal
const appId = process.env.SINCH_APP_ID;
const senderId = process.env.SINCH_SENDER_ID;
const sinchApiBaseUrl = process.env.SINCH_API_BASE_URL;
// JWT_SECRET is intentionally not validated here as it's for internal use, not Sinch auth
if (!projectId || !accessKeyId || !accessKeySecret || !appId || !senderId || !sinchApiBaseUrl) {
console.error(""FATAL ERROR: Missing required Sinch environment variables."");
// In a real app, throw an error to prevent startup or handle appropriately
throw new Error(""Missing required Sinch environment variables."");
}
// --- End Environment Variable Validation ---
// --- Sinch Authentication (Bearer Token Generation - PLACEHOLDER) ---
// **CRITICAL**: You MUST verify the correct authentication method with the official
// Sinch Conversation API documentation for the endpoint being used.
// Common methods include:
// 1. Basic Authentication: `Authorization: Basic <base64(keyId:keySecret)>`
// 2. Bearer Token (Short-lived JWT): Generated using Key ID/Secret, potentially specific claims.
// 3. Bearer Token (Long-lived): A pre-generated token from the Sinch portal.
//
// The function below provides examples but is a GUESS. DO NOT use in production without verification.
async function getSinchAccessToken(): Promise<string> {
console.warn(""Using PLACEHOLDER Sinch authentication logic. VERIFY with official Sinch documentation!"");
// ** Option 1: Pre-generated Long-Lived Token (if available & preferred) **
// If your `SINCH_ACCESS_KEY` *is* the long-lived token provided by Sinch:
// return `Bearer ${accessKeySecret}`;
// ** Option 2: Basic Authentication (if supported by the endpoint) **
// const credentials = Buffer.from(`${accessKeyId}:${accessKeySecret}`).toString('base64');
// return `Basic ${credentials}`;
// ** Option 3: Generating a Short-Lived JWT (EXAMPLE - VERIFY CLAIMS & SIGNING) **
// This example assumes HS256 signing with the Key Secret. Sinch might require different claims,
// algorithm (e.g., RS256 with a private key), or a call to a token endpoint.
try {
const now = Math.floor(Date.now() / 1000);
const expiry = now + 3600; // Token expires in 1 hour
const claims = {
// Common JWT claims (verify exact requirements with Sinch)
iss: 'self', // Or your specific issuer identifier if required
// aud: 'https://conversationapi.sinch.com', // Example audience
iat: now,
exp: expiry,
// Sinch-specific claims (VERIFY THESE)
keyid: accessKeyId, // Often required
project_id: projectId, // Often required
// application_id: appId, // Sometimes required
};
// Sign using the Key Secret (SINCH_ACCESS_KEY)
// **VERIFY THE ALGORITHM (HS256, RS256, etc.) with Sinch Docs**
const token = jwt.sign(claims, accessKeySecret, { algorithm: 'HS256' });
return `Bearer ${token}`;
} catch (error) {
console.error(""Error generating Sinch JWT (using placeholder logic):"", error);
throw new Error(""Failed to generate Sinch authentication token."");
}
// ** Fallback/Error **
// throw new Error(""No valid Sinch authentication method configured or verified."");
}
// --- Axios Instance for Sinch API ---
const sinchApiClient = axios.create({
baseURL: sinchApiBaseUrl,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
// Add timeout to prevent hanging requests
timeout: 15000, // 15 seconds
});
// Add an interceptor to dynamically add the Authorization header
sinchApiClient.interceptors.request.use(async (config) => {
try {
// Get a fresh token for each request (consider caching for short periods if generating JWTs)
const token = await getSinchAccessToken();
config.headers.Authorization = token;
return config;
} catch (error) {
console.error(""Error setting Sinch auth header:"", error);
// Prevent the request if token generation fails
return Promise.reject(new Error('Failed to set Sinch authentication header.'));
}
}, (error) => {
return Promise.reject(error);
});
// --- Send Message Function ---
export interface SendMessageResult {
success: boolean;
messageId?: string;
error?: string;
recipient: string;
status?: number; // HTTP status code for context
}
export async function sendSinchMessage(recipient: string, messageBody: string): Promise<SendMessageResult> {
// Basic phone number validation (can be enhanced with libphonenumber-js)
if (!/^\+?[1-9]\d{1,14}$/.test(recipient)) {
console.warn(`Invalid phone number format skipped: ${recipient}`);
return { success: false, error: 'Invalid phone number format', recipient, status: 400 };
}
const endpoint = `/v1/projects/${projectId}/messages:send`;
// **VERIFY PAYLOAD STRUCTURE WITH SINCH CONVERSATION API DOCS**
// The fields below (especially `recipients`, `contact_id`, `sms_message.from`)
// might differ based on the exact API version and channel requirements.
const payload = {
app_id: appId,
// `recipients` structure: Check if it should be `[{ phone_number: recipient }]` or similar.
recipients: [{ contact_id: recipient }], // Assuming contact_id is the E.164 number
message: {
text_message: {
text: messageBody,
},
},
// Define the channel and potentially sender identity explicitly
channel_priority_order: [""SMS""], // Prioritize SMS
// `sms_message`: Check if this block is needed and if `from` is the correct field for sender ID.
sms_message: {
from: senderId, // Specify sender for SMS channel
},
// Other potential fields: `callback_url`, `processing_strategy`, etc.
};
try {
console.log(`Attempting to send message via Sinch to ${recipient}...`);
const response = await sinchApiClient.post(endpoint, payload);
// Check Sinch response structure for success indication and message ID
// This depends heavily on the actual API response format
const messageId = response.data?.message_id || response.data?.id || 'N/A'; // Adjust based on actual response
// Typically 200, 201, or 202 indicate acceptance by Sinch
if (response.status >= 200 && response.status < 300) {
console.log(`Message accepted by Sinch for ${recipient}. Message ID: ${messageId}_ Status: ${response.status}`);
return { success: true_ messageId: messageId_ recipient_ status: response.status };
} else {
// This case might not be hit if Axios throws on non-2xx status by default
console.warn(`Sinch API returned non-success status ${response.status} for ${recipient}:`_ response.data);
return { success: false_ error: `Sinch API Error: Status ${response.status}`_ recipient_ status: response.status };
}
} catch (error: any) {
let errorMessage = 'Unknown Sinch API error';
let status = 500; // Default internal error
if (axios.isAxiosError(error)) {
status = error.response?.status || 500;
const errorData = error.response?.data;
const sinchError = errorData?.error?.message || JSON.stringify(errorData); // Try to extract Sinch error message
errorMessage = `Sinch API Error (${status}): ${sinchError || error.message}`;
console.error(`Sinch API error for ${recipient}: Status ${status}_ Data:`_ errorData || error.message);
} else {
console.error(`Non-Axios error sending to ${recipient}:`_ error);
errorMessage = error.message || 'An unexpected error occurred during Sinch request.';
}
return { success: false_ error: errorMessage_ recipient_ status };
}
}
// --- Retry Mechanism ---
// Wrapper function to add retry logic to sendSinchMessage
export async function sendSinchMessageWithRetry(
recipient: string_
messageBody: string_
maxRetries = 2 // Total attempts = 1 + maxRetries
): Promise<SendMessageResult> {
let attempt = 0;
while (attempt <= maxRetries) {
const result = await sendSinchMessage(recipient_ messageBody);
// If successful_ return immediately
if (result.success) {
return result;
}
attempt++;
// Check if the error is potentially retryable and if we haven't exceeded max retries
const isRetryable = result.status === 429 || result.status >= 500; // Retry on Rate Limit or Server Error
const shouldRetry = isRetryable && attempt <= maxRetries;
if (!shouldRetry) {
if (isRetryable) {
console.warn(`Send failed permanently for ${recipient} after ${attempt} attempts. Error: ${result.error}`);
}
// Don't retry non-retryable errors (e.g._ 400 Bad Request_ 401 Auth Error)
return result; // Return the final failure result
}
// Calculate delay using exponential backoff with jitter
const delay = Math.pow(2_ attempt - 1) * 200 + Math.random() * 100; // Exponential backoff (e.g._ ~200ms_ ~400ms_ ~800ms...)
console.log(`Retrying send for ${recipient} (attempt ${attempt}/${maxRetries + 1}) after ${Math.round(delay)}ms due to status ${result.status}...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
// Should ideally not be reached if logic is correct, but acts as a final safety net
return { success: false, error: `Max retries (${maxRetries}) exceeded`, recipient, status: -1 };
}
- Environment Validation: Checks required env vars.
getSinchAccessToken
(CRITICAL PLACEHOLDER): This function now clearly highlights that the authentication logic MUST BE VERIFIED with Sinch documentation. It provides commented examples of common methods (Long-lived token, Basic Auth, JWT signed withSINCH_ACCESS_KEY
). The JWT example is a guess and needs validation.- Axios Instance & Interceptor: Sets up
axios
with base URL, timeout, and an interceptor to automatically add the (placeholder)Authorization
header. sendSinchMessage
:- Performs basic E.164 check.
- Constructs the payload for the
/v1/projects/{projectId}/messages:send
endpoint. Includes warnings to verify the payload structure with Sinch docs. - Makes the POST request using
sinchApiClient
. - Handles success and Axios error responses, extracting status code and error details, returning a structured
SendMessageResult
.
sendSinchMessageWithRetry
:- Wraps
sendSinchMessage
. - Retries sending only on specific retryable HTTP status codes (429, 5xx).
- Uses exponential backoff with jitter to calculate delays between retries.
- Logs retry attempts.
- Wraps
2.3 Backend API Route (src/app/api/send-bulk/route.ts
):
Create the API route handler.
// src/app/api/send-bulk/route.ts
import { NextResponse } from 'next/server';
import { sendSinchMessageWithRetry, SendMessageResult } from '@/lib/sinch'; // Use retry wrapper
export async function POST(request: Request) {
console.log(""Received request on /api/send-bulk"");
let requestBody;
try {
requestBody = await request.json();
} catch (error) {
console.error(""Error parsing request body:"", error);
return NextResponse.json({ success: false, error: 'Invalid request body. Must be JSON.' }, { status: 400 });
}
const { recipients, message } = requestBody;
// --- Input Validation ---
if (!Array.isArray(recipients) || recipients.length === 0) {
return NextResponse.json({ success: false, error: '`recipients` must be a non-empty array of strings.' }, { status: 400 });
}
if (typeof message !== 'string' || !message.trim()) {
return NextResponse.json({ success: false, error: '`message` must be a non-empty string.' }, { status: 400 });
}
// Consider adding more validation (e.g., max recipients, message length)
const MAX_RECIPIENTS = 1000; // Example limit
if (recipients.length > MAX_RECIPIENTS) {
return NextResponse.json({ success: false, error: `Too many recipients. Maximum allowed is ${MAX_RECIPIENTS}.` }, { status: 400 });
}
// --- End Input Validation ---
// --- Rate Limiting (Conceptual - Implement with a library) ---
// In production, add rate limiting here using libraries like '@upstash/ratelimit'
// const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1';
// const { success } = await rateLimiter.limit(ip);
// if (!success) {
// console.warn(`Rate limit exceeded for IP: ${ip}`);
// return NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 });
// }
// --- End Rate Limiting ---
console.log(`Initiating bulk send to ${recipients.length} recipients.`);
// Send messages concurrently using the retry wrapper
// Use Promise.allSettled to wait for all attempts, even failures
const sendPromises: Promise<SendMessageResult>[] = recipients.map(recipient =>
sendSinchMessageWithRetry(String(recipient), message) // Ensure recipient is string, call retry wrapper
);
let results: SendMessageResult[] = [];
try {
const settledResults = await Promise.allSettled(sendPromises);
results = settledResults.map((result, index) => {
const recipient = String(recipients[index]); // Get corresponding recipient
if (result.status === 'fulfilled') {
// Promise resolved (sendSinchMessageWithRetry returned a result)
return result.value; // Contains { success: boolean, messageId?, error?, recipient, status? }
} else {
// Promise rejected (unexpected error in sendSinchMessageWithRetry itself, less likely)
console.error(`Unexpected failure sending to ${recipient}:`, result.reason);
return {
success: false,
error: result.reason?.message || 'Unknown error during promise execution',
recipient: recipient,
status: 500 // Indicate internal server error
};
}
});
} catch (error) {
// Catch errors in the Promise.allSettled processing itself (unlikely)
console.error(""Catastrophic error processing bulk send results:"", error);
return NextResponse.json({ success: false, error: 'Server error processing send results' }, { status: 500 });
}
const sentCount = results.filter(r => r.success).length;
const failedCount = results.length - sentCount;
// Collect details only for failures
const failures = results
.filter(r => !r.success)
.map(r => ({ recipient: r.recipient, error: r.error, status: r.status }));
console.log(`Bulk send processing completed. Accepted by Sinch: ${sentCount}, Failed Attempts: ${failedCount}`);
if (failedCount > 0) {
console.warn(""Failures occurred:"", JSON.stringify(failures, null, 2)); // Log failure details
}
// Return a summary response
return NextResponse.json({
success: true, // Indicates the API call itself succeeded in processing
sentCount, // Count of messages accepted by Sinch (after retries)
failedCount, // Count of messages that failed all attempts
// Optionally include failure details, but be mindful of response size for large lists
failures: failedCount > 0 ? failures : undefined,
});
}
- Imports:
NextResponse
,sendSinchMessageWithRetry
,SendMessageResult
. POST
Handler: Standard structure for Next.js App Router API routes.- Body Parsing & Validation: Safely parses JSON, validates
recipients
array andmessage
string, includes an example max recipient limit. - Rate Limiting Placeholder: Comments indicate where to integrate rate limiting.
- Concurrent Sending with Retries: Uses
recipients.map
withsendSinchMessageWithRetry
andPromise.allSettled
. This ensures each message attempt benefits from the retry logic, and the API waits for all attempts to complete or fail definitively. - Result Aggregation: Processes
allSettled
results, distinguishing between fulfilled promises (which contain theSendMessageResult
) and rejected promises (unexpected errors). Counts successes and failures, collecting details for failures. - Response: Returns a JSON response summarizing the outcome (
sentCount
,failedCount
, optionalfailures
details).
3. Building a Complete API Layer
The /api/send-bulk/route.ts
file constitutes our basic API layer.
- Authentication/Authorization:
- End-User Auth: This example lacks end-user authentication. In a real app, protect this API route using methods like NextAuth.js or Clerk to ensure only logged-in, authorized users can trigger sends. You'd verify the session/token within the
POST
handler. - Service Auth: The route authenticates with Sinch using API keys stored securely as environment variables.
- End-User Auth: This example lacks end-user authentication. In a real app, protect this API route using methods like NextAuth.js or Clerk to ensure only logged-in, authorized users can trigger sends. You'd verify the session/token within the
- Request Validation: Robust validation is crucial. The example includes basic checks. Enhance using libraries like
zod
for schema validation, including format checks (e.g., E.164 for numbers) and length limits.// Example using Zod (install zod: npm install zod) import { z } from 'zod'; const phoneRegex = /^\+?[1-9]\d{1,14}$/; // Basic E.164 regex const sendBulkSchema = z.object({ recipients: z.array(z.string().regex(phoneRegex, { message: "Invalid phone number format. Use E.164." })) .min(1, { message: "Recipients array cannot be empty." }) .max(1000, { message: "Maximum 1000 recipients allowed." }), // Example max limit message: z.string() .trim() .min(1, { message: "Message cannot be empty." }) .max(1600, { message: "Message exceeds maximum length." }), // Example SMS max length (check channel specifics) }); // Inside POST handler, before processing: // const validationResult = sendBulkSchema.safeParse(requestBody); // if (!validationResult.success) { // return NextResponse.json( // { success: false, error: 'Validation failed.', details: validationResult.error.flatten().fieldErrors }, // { status: 400 } // ); // } // Use validated data: validationResult.data.recipients, validationResult.data.message // const { recipients, message } = validationResult.data; // Destructure validated data
- API Endpoint Documentation (Summary):
- Endpoint:
POST /api/send-bulk
- Description: Accepts a list of E.164 phone numbers and a message, attempts to send via Sinch SMS (with retries), and returns a summary.
- Request Body (JSON):
{ "recipients": ["+1...", "+44..."], "message": "..." }
- Success Response (200 OK):
{ "success": true, "sentCount": N, "failedCount": M, "failures": [...] }
(failures optional) - Error Responses: 400 (Bad Request/Validation), 429 (Rate Limit), 500 (Server Error).
- Endpoint:
- Testing with
curl
:(Replacecurl -X POST http://localhost:3000/api/send-bulk \ -H "Content-Type: application/json" \ -d '{ "recipients": ["+1YOUR_TEST_NUMBER_1", "+1YOUR_TEST_NUMBER_2"], "message": "Hello from curl test!" }'
localhost:3000
and use valid test numbers).
4. Integrating with Third-Party Services (Sinch)
This section details obtaining and configuring Sinch credentials.
(Note: This section seems redundant as credential setup was covered in Section 1.3. Ensure the information here aligns or remove redundancy if necessary.)
Ensure you have correctly obtained the following from your Sinch dashboard and placed them in .env.local
:
SINCH_PROJECT_ID
SINCH_ACCESS_KEY_ID
SINCH_ACCESS_KEY
(Secret)SINCH_APP_ID
SINCH_SENDER_ID
(Your provisioned number/ID linked to the App ID)SINCH_API_BASE_URL
(Correct regional URL)
Critical: The authentication mechanism (getSinchAccessToken
in src/lib/sinch.ts
) must be verified against the official Sinch Conversation API documentation for the /messages:send
endpoint. Do not assume the placeholder JWT logic is correct. Check if Basic Auth or a different token format is required.
5. Security Considerations
- Environment Variables: Keep
.env.local
out of version control (.gitignore
). Use platform-specific environment variable management for deployment (Vercel, AWS Secrets Manager, etc.). - Input Validation: Sanitize and validate all inputs (
recipients
,message
) on the server-side (/api/send-bulk
) to prevent injection attacks or malformed requests. Use libraries likezod
. Validate phone number formats strictly (e.g., usinglibphonenumber-js
for more robust validation than regex). Limit message length and recipient count. - Rate Limiting: Implement rate limiting on the API endpoint (
/api/send-bulk
) to prevent abuse and manage costs. Use libraries like@upstash/ratelimit
with Redis or similar solutions. Limit based on IP address, user ID (if authenticated), or other factors. - Authentication: Protect the API endpoint so only authorized users or systems can trigger bulk sends. Use NextAuth.js, Clerk, or similar for user authentication, or API keys/tokens for system-to-system communication.
- Error Handling: Avoid leaking sensitive information (like full error stacks or internal paths) in API error responses sent to the client. Log detailed errors server-side.
- Sinch Credentials: Treat
SINCH_ACCESS_KEY
as highly sensitive. Ensure it's stored securely and never exposed client-side. Rotate keys periodically if possible. - Dependencies: Keep dependencies (
npm
/yarn
/pnpm
) updated to patch security vulnerabilities (npm audit fix
or similar).
6. Error Handling and Logging
- API Route: The
/api/send-bulk
route usestry...catch
for request parsing andPromise.allSettled
to handle individual send failures gracefully. It logs errors and returns structured JSON responses (including failure details). - Sinch Utility:
sendSinchMessage
catches Axios errors, extracts status codes and Sinch error messages, and returns aSendMessageResult
.sendSinchMessageWithRetry
handles retry logic based on status codes and logs retry attempts. - Frontend: The
HomePage
component usestry...catch
for theaxios.post
call and updates the UI based on the API response (success
,error
, counts). It displays user-friendly status messages. - Logging: Use a structured logging library (e.g.,
pino
,winston
) instead ofconsole.log
in production for better log management, filtering, and integration with monitoring services. Log key events: request received, validation success/failure, Sinch request initiation, Sinch response (success/failure, ID), retry attempts, final outcome. Include correlation IDs to track requests.
7. Deployment
- Platform: Choose a hosting platform for Next.js (Vercel, Netlify, AWS Amplify, self-hosting with Node.js).
- Environment Variables: Configure your production environment variables securely on the chosen platform. Do not commit
.env.local
. - Build: Run
npm run build
(oryarn build
,pnpm build
) to create an optimized production build. - Start: Use the platform's deployment mechanism or run
npm start
(oryarn start
,pnpm start
) to serve the application. - HTTPS: Ensure your deployment uses HTTPS. Most platforms handle this automatically.
- Monitoring: Set up monitoring and alerting (e.g., Sentry, Datadog, platform-specific tools) to track application health, errors, and API performance.
- Rate Limiting Service: Ensure your rate limiting infrastructure (e.g., Redis instance for Upstash) is available and configured for the production environment.
8. Conclusion and Next Steps
You have successfully built a Next.js application that can send bulk messages via the Sinch Conversation API. This includes a basic frontend, a robust API route with validation and error handling, and integration with Sinch using secure practices.
Potential Enhancements:
- Robust Phone Validation: Integrate
libphonenumber-js
fully in the API route for stricter E.164 validation and formatting. - User Authentication: Add user login/signup (NextAuth.js, Clerk) to protect the sending functionality.
- Contact Management: Store recipient lists in a database (e.g., PostgreSQL with Prisma) instead of manual input.
- Message Templates: Allow users to select pre-defined message templates.
- Scheduling: Implement message scheduling capabilities.
- Delivery Status Tracking: Use Sinch webhooks (
callback_url
in the payload) to receive real-time delivery status updates and store/display them. - Channel Selection: Extend the UI and API to support other Sinch channels (WhatsApp, etc.).
- Scalability: For very large lists, consider background job queues (e.g., BullMQ, Celery via an external service) to process sends asynchronously instead of relying solely on serverless function timeouts.
- Advanced Rate Limiting: Implement more sophisticated rate limiting strategies.
- UI Improvements: Enhance the frontend with better loading indicators, progress bars for large sends, and detailed result displays.