This guide provides a step-by-step walkthrough for setting up a Next.js application to receive and process inbound SMS messages sent via the Sinch platform. We will build a robust webhook handler that listens for incoming messages, verifies their authenticity, processes the payload, and stores the message data.
Handling inbound messages is crucial for building interactive applications, enabling features like two-way conversations, user confirmations via SMS, support bots, and more. By leveraging Next.js API Routes and Sinch's callback mechanism, we can create a scalable and reliable solution.
Technologies Used:
- Next.js: A React framework for building full-stack web applications. We'll use its API Routes feature to create the webhook endpoint.
- Sinch SMS API: Used for sending and, in this case, receiving SMS messages. We'll configure its callback (webhook) feature.
- Node.js: The underlying runtime environment for Next.js.
- Prisma (Optional but Recommended): A Next-generation ORM for Node.js and TypeScript, used for database interaction.
- TypeScript: For type safety and improved developer experience.
- ngrok (for local development): A tool to expose local servers to the internet securely.
System Architecture:
The flow for an inbound message looks like this:
- A user sends an SMS to your provisioned Sinch phone number.
- Sinch receives the SMS.
- Sinch sends an HTTP POST request (webhook) containing the message details to a pre-configured Callback URL hosted by your Next.js application.
- Your Next.js API Route receives the POST request.
- (Optional but Recommended) The API Route verifies the request signature using a shared secret to ensure it genuinely came from Sinch.
- The API Route parses the JSON payload containing the message details (sender, content, timestamp, etc.).
- The API Route processes the message (e.g., logs it, stores it in a database, triggers business logic).
- The API Route responds to Sinch with an HTTP
200 OK
status to acknowledge receipt. Failure to respond correctly may cause Sinch to retry the webhook.
sequenceDiagram
participant User
participant SinchPlatform as Sinch Platform
participant NextJsApp as Next.js App (API Route)
participant Database (Optional)
User->>+SinchPlatform: Sends SMS to Sinch Number
SinchPlatform->>+NextJsApp: POST /api/sinch/inbound (Webhook)
NextJsApp->>NextJsApp: Verify Signature (HMAC-SHA256)
alt Signature Valid
NextJsApp->>NextJsApp: Parse JSON Payload
NextJsApp->>+Database: Store Message Data
Database-->>-NextJsApp: Confirm Save
NextJsApp-->>-SinchPlatform: HTTP 200 OK
else Signature Invalid
NextJsApp-->>-SinchPlatform: HTTP 401 Unauthorized (or similar)
end
SinchPlatform-->>-User: (SMS Delivery handled internally)
Prerequisites:
- A Sinch account with access to the SMS API.
- A provisioned phone number within your Sinch account capable of receiving SMS.
- Node.js (v18 or later recommended) and npm/yarn installed.
- Basic familiarity with Next.js, React, TypeScript, and APIs.
ngrok
installed globally (npm install -g ngrok
) for local testing.- Access to a terminal or command prompt.
- A code editor (e.g., VS Code).
- (Optional) A database (e.g., PostgreSQL) accessible for storing messages.
By the end of this guide, you will have a functional Next.js endpoint capable of securely receiving, verifying, and processing inbound SMS messages from Sinch, ready for integration into your broader application logic.
1. Setting Up the Project
Let's start by creating a new Next.js project and setting up the basic structure and environment.
Operating System Notes: The commands below use Unix-style syntax. Windows users might need to use copy
instead of cp
or adjust path separators if not using a Git Bash or WSL environment. Node.js and npm/yarn work cross-platform.
1. Create a New Next.js App:
Open your terminal and run the following command, replacing sinch-inbound-app
with your desired project name:
npx create-next-app@latest sinch-inbound-app --typescript --eslint --tailwind --app --src-dir --use-npm --import-alias ""@/*""
--typescript
: Enables TypeScript.--eslint
: Sets up ESLint for code linting.--tailwind
: Configures Tailwind CSS (optional, but common).--app
: Uses the App Router (recommended).--src-dir
: Creates asrc
directory for application code.--use-npm
: Uses npm instead of yarn.--import-alias ""@/*""
: Sets up path aliases.
Navigate into your new project directory:
cd sinch-inbound-app
2. Install Dependencies:
We need axios
if we plan to make outbound calls later, but for strictly receiving, no additional core dependencies are immediately required beyond Next.js itself. However, for robust implementation (validation, database), let's add Prisma and Zod. The base create-next-app
command with --typescript
already includes necessary development types like @types/node
, @types/react
, etc.
npm install prisma zod
prisma
: The ORM for database interaction.zod
: For schema declaration and validation of the incoming webhook payload.
3. Initialize Prisma (Optional Database Step): If you plan to store messages in a database (recommended):
npx prisma init --datasource-provider postgresql
- This creates a
prisma
directory with aschema.prisma
file and a.env
file for your database connection string. - Replace
postgresql
withmysql
,sqlite
,sqlserver
, ormongodb
if you prefer a different database.
4. Configure Environment Variables:
Next.js automatically loads variables from .env.local
. Create this file in your project root:
touch .env.local
Add the following variables. We'll get the Sinch values later.
# .env.local
# Database (if using Prisma)
# Example for PostgreSQL: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
DATABASE_URL=""your_database_connection_string""
# Sinch Webhook Security
# Obtain this from your Sinch dashboard or set a secure random string
# MANDATORY: Ensure this matches the secret configured in Sinch dashboard exactly.
SINCH_CALLBACK_SECRET=""your_very_secure_random_secret_string""
# Sinch API Credentials (Needed if sending replies, good to have regardless)
SINCH_SERVICE_PLAN_ID=""your_sinch_service_plan_id""
SINCH_API_TOKEN=""your_sinch_api_token""
SINCH_REGION_URL=""https://us.sms.api.sinch.com"" # Or eu.sms.api.sinch.com etc.
DATABASE_URL
: Your full database connection string. Make sure your database server is running and accessible.SINCH_CALLBACK_SECRET
: A secret string known only to your application and Sinch. Used to verify webhook signatures. Generate a strong random string for this.SINCH_SERVICE_PLAN_ID
,SINCH_API_TOKEN
,SINCH_REGION_URL
: Find these in your Sinch Customer Dashboard under SMS -> APIs. The Region URL depends on your account setup. These aren't strictly required for receiving only, but crucial for signature verification logic or sending replies.
Important: Add .env.local
to your .gitignore
file to avoid committing secrets. The create-next-app
template usually does this automatically.
5. Project Structure:
The relevant structure within the src
directory will be:
src/
├── app/
│ ├── api/
│ │ └── sinch/
│ │ └── inbound/
│ │ └── route.ts <-- Our webhook handler
│ ├── layout.tsx
│ └── page.tsx
├── lib/ <-- Utility functions_ Prisma client_ etc.
│ ├── prisma.ts <-- Prisma client instance (if using DB)
│ ├── sinchUtils.ts <-- Sinch related utilities (e.g._ signature verification)
│ └── schemas.ts <-- Zod schemas
└── prisma/
└── schema.prisma <-- Database schema definition
This structure separates API logic from UI components and provides dedicated places for utilities and database definitions.
2. Implementing the Core Webhook Handler
Now_ let's create the API route that will receive the webhook POST requests from Sinch.
1. Create the API Route File: Create the necessary directories and the file:
mkdir -p src/app/api/sinch/inbound
touch src/app/api/sinch/inbound/route.ts
2. Implement the POST
Handler:
Open src/app/api/sinch/inbound/route.ts
and add the following basic implementation:
// src/app/api/sinch/inbound/route.ts
import { NextRequest_ NextResponse } from 'next/server';
import { verifySinchSignature } from '@/lib/sinchUtils'; // We will create this util later
export async function POST(request: NextRequest) {
console.log('Received request on /api/sinch/inbound');
// 1. --- Verify Signature (CRITICAL SECURITY STEP) ---
// We need the raw body for signature verification_ BEFORE parsing JSON
const rawBody = await request.text();
// MANDATORY: Confirm the exact header name (e.g._ 'x-sinch-signature')
// from the official Sinch documentation. It might differ.
const signatureHeader = request.headers.get('x-sinch-signature'); // Example header name
if (!verifySinchSignature(signatureHeader_ rawBody)) {
console.error('Invalid Sinch signature');
// Do not provide detailed error messages for security reasons.
return new NextResponse('Invalid signature'_ { status: 401 });
}
console.log('Sinch signature verified successfully.');
// 2. --- Parse the JSON Payload ---
let payload: any;
try {
// Now parse the verified raw body
payload = JSON.parse(rawBody);
console.log('Parsed Sinch Payload:'_ JSON.stringify(payload_ null_ 2));
} catch (error) {
console.error('Failed to parse JSON payload:'_ error);
return new NextResponse('Invalid JSON payload'_ { status: 400 });
}
// 3. --- Process the Payload ---
// Example: Log the message details (Structure depends on actual payload)
// MANDATORY: Verify the actual payload structure from Sinch docs.
// Example structure - adjust based on documentation:
const { from_ to_ body_ type_ id_ received_at } = payload;
console.log(`Received message ID ${id} from ${from} to ${to} at ${received_at}`);
console.log(`Type: ${type}_ Body: ${body}`);
// --- Add your business logic here ---
// - Store the message in the database (see Section 6)
// - Trigger other actions based on the message content
// - Send an automated reply (would require another API call to Sinch)
// 4. --- Acknowledge Receipt ---
// IMPORTANT: Respond with 200 OK quickly to prevent Sinch retries
return new NextResponse('OK'_ { status: 200 });
}
// Optional: Handle other methods if needed_ e.g._ GET for health checks
export async function GET() {
return new NextResponse('Sinch inbound webhook endpoint is active.'_ { status: 200 });
}
Explanation:
POST(request: NextRequest)
: This function handles incoming POST requests to/api/sinch/inbound
.- Signature Verification:
- We read the raw request body using
request.text()
. This is essential because signature verification hashes the raw_ unparsed body. - We retrieve the signature header (e.g._
x-sinch-signature
). Crucially_ you must verify the correct header name in the official Sinch documentation. - We call
verifySinchSignature
(created in Section 7 - Note: Section 7 is not provided in the original text_ this utility needs to be implemented) to compare the expected signature with the one received. - If verification fails_ we return
401 Unauthorized
immediately. Never process unverified webhooks.
- We read the raw request body using
- JSON Parsing: Only after successful signature verification do we parse the
rawBody
into a JavaScript object usingJSON.parse()
. - Payload Processing: We extract relevant fields from the
payload
. The exact fields and structure must be confirmed with Sinch documentation. This is where you'll add your application-specific logic. - Acknowledge Receipt: We return a
NextResponse
with a200 OK
status. This tells Sinch we've successfully received the webhook. Do this promptly; complex processing should ideally happen asynchronously (e.g._ via a job queue) to avoid timeouts.
3. Building the API Layer
In this specific scenario_ the API layer is the webhook handler (route.ts
) we just created. However_ let's refine it with proper validation and structure.
1. Request Validation with Zod: It's good practice to validate the structure of the incoming payload even after signature verification.
Install Zod if you haven't already:
npm install zod
Define a Zod schema matching the expected Sinch inbound SMS payload.
// src/lib/schemas.ts (Create this file)
import { z } from 'zod';
// WARNING: This schema is illustrative. You MUST verify the exact payload
// structure_ field names_ types_ and optionality against the official
// Sinch API documentation for inbound SMS webhooks and adapt this schema accordingly.
export const sinchInboundSmsSchema = z.object({
id: z.string().min(1)_
from: z.string().min(1)_ // E.164 format phone number likely
to: z.string().min(1)_ // Your Sinch number likely
type: z.literal('mo_text')_ // Check Sinch docs for all possible types ('mo_binary' etc.)
body: z.string()_ // Message content (can be empty string)
received_at: z.string().datetime({ offset: true })_ // ISO 8601 format likely
sent_at: z.string().datetime({ offset: true }).optional()_ // Verify if always present
operator_id: z.string().optional()_ // Verify presence and type
client_reference: z.string().optional()_ // Verify presence and type
// Add/remove/modify fields based on official Sinch documentation!
});
export type SinchInboundSmsPayload = z.infer<typeof sinchInboundSmsSchema>;
Important: The schema above is an example. You must consult the official Sinch documentation for the accurate structure of the inbound SMS webhook payload and update this Zod schema to match it precisely. Failure to do so will likely lead to validation errors or incorrect data processing.
Now, use this schema in your API route:
// src/app/api/sinch/inbound/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifySinchSignature } from '@/lib/sinchUtils';
import { sinchInboundSmsSchema, SinchInboundSmsPayload } from '@/lib/schemas'; // Import schema
export async function POST(request: NextRequest) {
console.log('Received request on /api/sinch/inbound');
const rawBody = await request.text();
// MANDATORY: Confirm the exact header name from Sinch docs.
const signatureHeader = request.headers.get('x-sinch-signature'); // Example header name
if (!verifySinchSignature(signatureHeader, rawBody)) {
console.error('Invalid Sinch signature');
return new NextResponse('Invalid signature', { status: 401 });
}
console.log('Sinch signature verified successfully.');
let payloadJson: any;
try {
payloadJson = JSON.parse(rawBody);
} catch (error) {
console.error('Failed to parse JSON payload:', error);
return new NextResponse('Invalid JSON payload', { status: 400 });
}
// --- Validate Payload Structure ---
// Uses the Zod schema defined in '@/lib/schemas'
// Remember to keep that schema updated based on Sinch documentation.
const validationResult = sinchInboundSmsSchema.safeParse(payloadJson);
if (!validationResult.success) {
console.error('Invalid payload structure:', validationResult.error.errors);
// Log the specific validation errors for debugging
// Return 400 Bad Request
return new NextResponse(
JSON.stringify({ message: 'Invalid payload structure', errors: validationResult.error.flatten() }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
// --- Type-safe payload ---
const payload: SinchInboundSmsPayload = validationResult.data;
console.log('Validated Sinch Payload:', JSON.stringify(payload, null, 2));
// --- Process the Validated Payload ---
const { from, to, body, type, id, received_at } = payload;
console.log(`Processing message ID ${id} from ${from} to ${to}`);
console.log(`Body: ${body}`);
// --- Add business logic here (e.g., Database interaction - Section 6) ---
// try {
// await prisma.inboundSms.create({ data: { /* map payload fields */ }}); // Example
// } catch (dbError) {
// console.error(""Database error:"", dbError);
// // Decide if this is a retryable error (5xx) or not (maybe still return 200 to Sinch?)
// return new NextResponse('Internal Server Error', { status: 500 });
// }
return new NextResponse('OK', { status: 200 });
}
// Keep the GET handler
export async function GET() {
return new NextResponse('Sinch inbound webhook endpoint is active.', { status: 200 });
}
2. API Documentation (Self-Documenting): The Zod schema provides a form of documentation. For more formal documentation, consider tools like Swagger/OpenAPI, although for a single internal webhook, comments and the schema might suffice.
3. Testing the Endpoint:
You can't easily test the POST
endpoint with a browser. Use tools like curl
or Postman, but remember:
- You need a valid payload structure (matching your Zod schema, which should match Sinch docs).
- You need to simulate the correct signature header (e.g.,
x-sinch-signature
), which requires knowing your secret and implementing the HMAC-SHA256 logic locally for testing.
Example curl
(without signature verification initially, just testing structure):
# Replace URL with localhost or ngrok tunnel
# Ensure payload matches your sinchInboundSmsSchema definition
curl -X POST http://localhost:3000/api/sinch/inbound \
-H ""Content-Type: application/json"" \
-d '{
""id"": ""01FC66621XXXXX119Z8PMV1QPA"",
""from"": ""+11234567890"",
""to"": ""+15551234567"",
""type"": ""mo_text"",
""body"": ""Hello from curl!"",
""received_at"": ""2025-04-20T10:00:00.000Z""
}'
To test with signature verification, you'd need to calculate the HMAC-SHA256 hash of the JSON payload using your SINCH_CALLBACK_SECRET
and include it in the correct signature header (confirm header name and format from Sinch docs).
4. Integrating with Sinch (Configuration)
This involves configuring your Sinch account to send webhooks to your Next.js application.
-
Obtain Sinch Credentials:
- Log in to your Sinch Customer Dashboard.
- Navigate to the SMS product section.
- Go to APIs.
- Note down your Service plan ID and API token. Ensure you select the correct region.
- Paste these into your
.env.local
file forSINCH_SERVICE_PLAN_ID
andSINCH_API_TOKEN
. Also setSINCH_REGION_URL
accordingly.
-
Configure the Callback URL:
-
In the same APIs section of your Sinch Dashboard, find your Service Plan ID and click on it.
-
Look for a section related to Callback URLs or Webhooks.
-
You need to provide a publicly accessible HTTPS URL for your webhook handler.
-
For Local Development:
- Start your Next.js dev server:
npm run dev
(usually runs on port 3000). - Open another terminal and run
ngrok
:ngrok http 3000
ngrok
will display forwarding URLs. Copy the HTTPS URL (e.g.,https://random-string.ngrok-free.app
).- Append your API route path:
https://random-string.ngrok-free.app/api/sinch/inbound
- Paste this full HTTPS URL into the Sinch Callback URL field.
- Start your Next.js dev server:
-
For Production:
- Deploy your Next.js application (e.g., to Vercel, Netlify, AWS).
- Get your production domain URL (e.g.,
https://your-app-name.vercel.app
). - Append your API route path:
https://your-app-name.vercel.app/api/sinch/inbound
- Paste this URL into the Sinch Callback URL field.
-
-
-
Configure Callback Secret (for Signature Verification):
- In the Sinch dashboard, likely near the Callback URL setting, look for an option to enable Signed Callbacks or configure a Webhook Secret or Signature Key.
- Enable this feature.
- Paste the exact same secure random string you generated for
SINCH_CALLBACK_SECRET
in your.env.local
file into the corresponding field in the Sinch dashboard. - Save the configuration in Sinch.
-
Assign Number to Service Plan:
- Ensure the phone number you want to receive messages on is assigned to the same Service Plan ID for which you configured the callback URL. You usually manage this under the Numbers section of the Sinch dashboard.
Environment Variables Recap:
SINCH_CALLBACK_SECRET
: (Required for security) A strong, random string. Must match exactly between.env.local
and the Sinch dashboard setting. Used byverifySinchSignature
.DATABASE_URL
: (Optional) Connection string for your database. Used by Prisma.SINCH_SERVICE_PLAN_ID
,SINCH_API_TOKEN
,SINCH_REGION_URL
: (Optional for receiving, needed for sending/some utils) Credentials from the Sinch dashboard.
5. Implementing Error Handling and Logging
Robust error handling and logging are vital for production systems.
1. Consistent Error Handling Strategy:
- Signature Verification Failure: Return
401 Unauthorized
. Log the failure clearly but avoid leaking sensitive info. - JSON Parsing Failure: Return
400 Bad Request
. Log the error. - Payload Validation Failure: Return
400 Bad Request
. Log the specific validation errors (from Zod). - Database/Business Logic Errors:
- Decide if the error is temporary (e.g., DB connection timeout) or permanent (e.g., invalid data constraint).
- For potentially temporary errors, you might return a
5xx
status (e.g.,500 Internal Server Error
or503 Service Unavailable
). This might cause Sinch to retry. Check Sinch's retry policy documentation. - For permanent errors or if you don't want retries, consider logging the error thoroughly but still returning
200 OK
to Sinch to acknowledge receipt and prevent unnecessary retries filling up logs. This depends heavily on your application's requirements.
- Catch-All: Wrap main processing logic in a
try...catch
block to handle unexpected errors and return a generic500
status, logging the stack trace.
2. Logging:
- Development:
console.log
andconsole.error
are acceptable. - Production:
- Use a structured logger like
pino
for better performance and machine-readable logs:npm install pino pino-pretty
(pino-pretty
for dev). - Log key events: receiving request, signature verification result, payload validation result, start/end of business logic, database interactions, errors with stack traces.
- Include unique identifiers (like the Sinch message ID
payload.id
) in logs to trace a specific request's journey. - Leverage platform logging (e.g., Vercel Logs) which automatically captures console output.
- Use a structured logger like
Example Refinement with try...catch
:
// src/app/api/sinch/inbound/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifySinchSignature } from '@/lib/sinchUtils';
import { sinchInboundSmsSchema, SinchInboundSmsPayload } from '@/lib/schemas';
// import { prisma } from '@/lib/prisma'; // Assuming Prisma setup from Section 6
export async function POST(request: NextRequest) {
// Placeholder ID for logging before payload is parsed
const preliminaryLogId = `req-${Date.now()}`;
let messageIdForLogs = preliminaryLogId;
try {
console.log(`[${messageIdForLogs}] Received request on /api/sinch/inbound`);
const rawBody = await request.text();
// MANDATORY: Confirm the exact header name from Sinch docs.
const signatureHeader = request.headers.get('x-sinch-signature'); // Example header name
if (!verifySinchSignature(signatureHeader, rawBody)) {
console.error(`[${messageIdForLogs}] Invalid Sinch signature`);
return new NextResponse('Invalid signature', { status: 401 });
}
console.log(`[${messageIdForLogs}] Sinch signature verified successfully.`);
let payloadJson: any;
try {
payloadJson = JSON.parse(rawBody);
} catch (error) {
console.error(`[${messageIdForLogs}] Failed to parse JSON payload:`, error);
return new NextResponse('Invalid JSON payload', { status: 400 });
}
// --- Use Sinch message ID for logging from now on, if available ---
messageIdForLogs = payloadJson?.id || preliminaryLogId;
console.log(`[${messageIdForLogs}] Processing message.`);
const validationResult = sinchInboundSmsSchema.safeParse(payloadJson);
if (!validationResult.success) {
console.error(`[${messageIdForLogs}] Invalid payload structure:`, validationResult.error.errors);
return new NextResponse(
JSON.stringify({ message: 'Invalid payload structure', errors: validationResult.error.flatten() }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const payload: SinchInboundSmsPayload = validationResult.data;
// Now messageIdForLogs is definitely the correct message ID from payload
messageIdForLogs = payload.id;
// --- Business Logic ---
try {
console.log(`[${messageIdForLogs}] Executing business logic (e.g., storing message)...`);
// Example: await storeMessageInDb(payload); // Your DB function (see Section 6)
console.log(`[${messageIdForLogs}] Business logic completed successfully.`);
} catch (logicError: any) {
console.error(`[${messageIdForLogs}] Error during business logic/DB operation:`, logicError);
// Decide on response: 500 might cause retry, 200 acknowledges receipt despite internal error.
// Check Sinch retry policy. Example: return 500 to indicate temporary failure.
return new NextResponse('Internal Server Error processing message', { status: 500 });
}
console.log(`[${messageIdForLogs}] Successfully processed.`);
return new NextResponse('OK', { status: 200 });
} catch (error: any) {
// Catch unexpected errors anywhere in the handler
console.error(`[${messageIdForLogs}] Unhandled error in webhook handler:`, error);
return new NextResponse('Internal Server Error', { status: 500 });
}
}
// Keep the GET handler
export async function GET() {
return new NextResponse('Sinch inbound webhook endpoint is active.', { status: 200 });
}
3. Retry Mechanisms:
Retry logic is generally handled by Sinch if your endpoint fails to return a 200 OK
within their timeout period. Your responsibility is to:
- Respond quickly (within a few seconds). Offload long tasks.
- Return appropriate status codes based on whether the error is retryable from Sinch's perspective (check their docs).
- Ensure your processing logic is idempotent – processing the same message multiple times due to retries should not cause duplicate data or incorrect state changes. Use the unique Sinch message
id
(payload.id
) to check if a message has already been processed.
6. Creating a Database Schema and Data Layer (with Prisma)
Let's define a schema to store the inbound messages using Prisma.
1. Define Prisma Schema:
Open prisma/schema.prisma
and define a model for inbound SMS messages:
// prisma/schema.prisma
generator client {
provider = ""prisma-client-js""
}
datasource db {
provider = ""postgresql"" // Or your chosen provider: ""mysql"", ""sqlite"", ""sqlserver"", ""mongodb""
url = env(""DATABASE_URL"")
}
// Represents an inbound SMS message received via Sinch webhook
model InboundSms {
// Use Sinch's unique message ID as the primary key for idempotency
id String @id @unique
// Map fields to avoid potential conflicts with Prisma/SQL keywords
// and to match expected payload field names (verify with Sinch docs)
sinchFrom String @map(""from"")
sinchTo String @map(""to"")
type String // e.g., ""mo_text"" (verify possible values)
body String? // Message body can be null or empty string
receivedAt DateTime @map(""received_at"") // Timestamp when Sinch received it
sentAt DateTime? @map(""sent_at"") // Timestamp when originated (if provided)
operatorId String? @map(""operator_id"") // Optional carrier info
clientReference String? @map(""client_reference"") // Optional reference you might set
// Timestamps managed by your application/database
createdAt DateTime @default(now()) // When the record was created in your DB
updatedAt DateTime @updatedAt // When the record was last updated
@@map(""inbound_sms"") // Optional: explicitly set the table name in the database
}
// Add other models as needed for your application logic
Explanation:
@id @unique
: Uses the uniqueid
provided by Sinch as the primary key. This helps ensure idempotency.@map(""from"")
,@map(""to"")
, etc.: Maps model fields to database column names, especially useful for reserved keywords or different naming conventions. Verify these map to actual field names in the Sinch payload.String?
,DateTime?
: Marks fields that might not always be present in the payload as optional. Verify optionality from Sinch docs. Note thatbody
might be an empty string rather than null, adjust schema accordingly based on documentation.createdAt
,updatedAt
: Standard timestamps managed by Prisma.@@map(""inbound_sms"")
: Explicitly sets the table name in the database.
2. Create Prisma Client Instance: Create a utility file to instantiate and export the Prisma client, ensuring only one instance is created in serverless environments.
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';
declare global {
// allow global `var` declarations
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
export const prisma =
global.prisma ||
new PrismaClient({
// Log database queries during development for debugging
log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
global.prisma = prisma;
}
3. Generate Prisma Client and Create Migration: Run these commands in your terminal:
# Ensure your DATABASE_URL in .env.local is correct and the database is running
# Generate Prisma Client types based on your schema
npx prisma generate
# Create a SQL migration file based on schema changes and apply it to the database
# Provide a descriptive name for the migration
npx prisma migrate dev --name init_inbound_sms_table
prisma generate
: Creates/updates the type-safe Prisma Client innode_modules/.prisma/client
.prisma migrate dev
: Creates a new SQL migration file inprisma/migrations
, applies it to your development database, and ensures the database schema matchesschema.prisma
.
4. Update API Route to Save Data:
Modify the POST
handler in src/app/api/sinch/inbound/route.ts
to use the Prisma client within the business logic block.
// src/app/api/sinch/inbound/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifySinchSignature } from '@/lib/sinchUtils';
import { sinchInboundSmsSchema, SinchInboundSmsPayload } from '@/lib/schemas';
import { prisma } from '@/lib/prisma'; // Import Prisma client
export async function POST(request: NextRequest) {
const preliminaryLogId = `req-${Date.now()}`;
let messageIdForLogs = preliminaryLogId;
try {
console.log(`[${messageIdForLogs}] Received request on /api/sinch/inbound`);
const rawBody = await request.text();
const signatureHeader = request.headers.get('x-sinch-signature'); // Example header name
if (!verifySinchSignature(signatureHeader, rawBody)) {
console.error(`[${messageIdForLogs}] Invalid Sinch signature`);
return new NextResponse('Invalid signature', { status: 401 });
}
console.log(`[${messageIdForLogs}] Sinch signature verified successfully.`);
let payloadJson: any;
try {
payloadJson = JSON.parse(rawBody);
} catch (error) {
console.error(`[${messageIdForLogs}] Failed to parse JSON payload:`, error);
return new NextResponse('Invalid JSON payload', { status: 400 });
}
messageIdForLogs = payloadJson?.id || preliminaryLogId;
console.log(`[${messageIdForLogs}] Processing message.`);
const validationResult = sinchInboundSmsSchema.safeParse(payloadJson);
if (!validationResult.success) {
console.error(`[${messageIdForLogs}] Invalid payload structure:`, validationResult.error.errors);
return new NextResponse(
JSON.stringify({ message: 'Invalid payload structure', errors: validationResult.error.flatten() }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const payload: SinchInboundSmsPayload = validationResult.data;
messageIdForLogs = payload.id; // Ensure log ID is the actual message ID
// --- Store Message in Database (Example Business Logic) ---
try {
console.log(`[${messageIdForLogs}] Attempting to store message in DB...`);
// Idempotency Check: See if a message with this ID already exists
const existingMessage = await prisma.inboundSms.findUnique({
where: { id: messageIdForLogs },
select: { id: true } // Only select needed field for check
});
if (existingMessage) {
// Message already processed, likely due to Sinch retry or duplicate webhook
console.warn(`[${messageIdForLogs}] Message already exists in DB. Skipping duplicate processing.`);
// IMPORTANT: Still return 200 OK to acknowledge receipt and stop Sinch retries.
} else {
// Message is new, create it in the database
// Map fields from the validated payload to the Prisma schema model
// Ensure date strings are converted to Date objects if needed by Prisma
const newMessage = await prisma.inboundSms.create({
data: {
id: payload.id,
sinchFrom: payload.from,
sinchTo: payload.to,
type: payload.type,
body: payload.body, // Prisma handles String? correctly
receivedAt: new Date(payload.received_at), // Convert ISO string to Date
sentAt: payload.sent_at ? new Date(payload.sent_at) : null, // Convert optional ISO string
operatorId: payload.operator_id, // Optional string
clientReference: payload.client_reference, // Optional string
// createdAt and updatedAt are handled by Prisma defaults
},
});
console.log(`[${messageIdForLogs}] New message stored successfully with DB ID: ${newMessage.id}`);
}
} catch (dbError: any) {
console.error(`[${messageIdForLogs}] Error during database operation:`, dbError);
// Decide on response: 500 might cause retry, 200 acknowledges receipt despite internal error.
// Returning 500 here as an example for potential retry on DB issues.
return new NextResponse('Internal Server Error processing message', { status: 500 });
}
console.log(`[${messageIdForLogs}] Successfully processed.`);
return new NextResponse('OK', { status: 200 });
} catch (error: any) {
console.error(`[${messageIdForLogs}] Unhandled error in webhook handler:`, error);
return new NextResponse('Internal Server Error', { status: 500 });
}
}
// Keep the GET handler
export async function GET() {
return new NextResponse('Sinch inbound webhook endpoint is active.', { status: 200 });
}