sms compliance

Sent logo
Sent TeamMar 8, 2026 / sms compliance / Sinch

Sinch Next.js Inbound SMS: Receive SMS Webhooks with Complete Tutorial (2025)

Build a production-ready Sinch SMS webhook handler with Next.js App Router. Step-by-step guide with HMAC-SHA256 verification, Zod validation, Prisma storage, and webhook retry handling for two-way messaging.

How to Receive Inbound SMS with Sinch and Next.js: Complete Webhook Tutorial

Build a production-ready SMS webhook handler with Sinch and Next.js to receive and process inbound SMS messages. This comprehensive tutorial walks you through implementing secure HMAC-SHA256 signature verification, validating webhook payloads with Zod, storing messages in a database with Prisma, and deploying a scalable two-way messaging system.

What you'll learn: How to set up Sinch SMS webhooks in Next.js v14/v15, implement HMAC-SHA256 signature verification for security, validate inbound SMS payloads with Zod v3.x, store messages using Prisma v5.x with PostgreSQL, handle webhook retries and idempotency, and deploy a production-ready inbound messaging system.

Technologies: Next.js v14.x/v15.x with App Router, Sinch SMS API, Node.js v18/v20 LTS, Prisma v5.x ORM, Zod v3.x validation, TypeScript, ngrok for local testing.

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. You'll build a robust webhook handler that listens for incoming messages, verifies their authenticity, processes the payload, and stores the message data.

Handling inbound SMS 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, you can create a scalable and reliable solution.

Technologies Used:

  • Next.js: A React framework for building full-stack web applications (v14.x or v15.x recommended as of October 2025). You'll use its App Router and API Routes feature to create the webhook endpoint.
  • Sinch SMS API: Used for sending and, in this case, receiving SMS messages. You'll configure its callback (webhook) feature.
  • Node.js: The underlying runtime environment for Next.js (v18 LTS or v20 LTS recommended as of October 2025).
  • Prisma (Optional but Recommended): A next-generation ORM for Node.js and TypeScript (v5.x), used for database interaction with type-safe queries.
  • TypeScript: For type safety and improved developer experience.
  • Zod: Schema validation library (v3.x) for runtime type checking of webhook payloads.
  • ngrok (for local development): A tool to expose local servers to the internet securely, enabling webhook testing during development.

System Architecture:

The flow for an inbound SMS message looks like this:

  1. A user sends an SMS to your provisioned Sinch phone number.
  2. Sinch receives the SMS.
  3. Sinch sends an HTTP POST request (webhook) containing the message details to a pre-configured Callback URL hosted by your Next.js application.
  4. Your Next.js API Route receives the POST request.
  5. (Optional but Recommended) The API Route verifies the request signature using HMAC-SHA256 with a shared secret to ensure it genuinely came from Sinch.
  6. The API Route parses the JSON payload containing the message details (sender, content, timestamp, etc.).
  7. The API Route processes the message (e.g., logs it, stores it in a database, triggers business logic).
  8. 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.
mermaid
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 LTS or v20 LTS installed (verify with node --version).
  • npm (v9+) or yarn (v1.22+) package manager installed.
  • Basic familiarity with Next.js, React, TypeScript, and REST APIs.
  • ngrok installed globally (npm install -g ngrok) for local webhook testing.
  • Access to a terminal or command prompt.
  • A code editor (e.g., VS Code).
  • (Optional) A database (e.g., PostgreSQL v14+, MySQL v8+, or SQLite) accessible for storing messages.

By the end of this guide, you'll 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.

Why Use Sinch and Next.js for Inbound SMS Webhooks?

Sinch provides a reliable SMS API platform with global reach, while Next.js offers a modern full-stack framework with built-in API route handling. Together, they enable you to build scalable two-way SMS applications with minimal infrastructure overhead.

Key Benefits:

  • Seamless Integration: Next.js App Router provides native webhook endpoints via route.ts files with dedicated HTTP method handlers
  • Type Safety: TypeScript and Zod validation ensure runtime and compile-time type checking for webhook payloads
  • Production-Ready Security: HMAC-SHA256 signature verification protects against unauthorized webhook requests
  • Database Integration: Prisma ORM simplifies storing and querying inbound SMS messages with type-safe queries
  • Scalable Deployment: Deploy to serverless platforms like Vercel, Netlify, or AWS with zero-configuration scaling
  • Developer Experience: Hot module reloading, built-in TypeScript support, and modern React patterns accelerate development

Common use cases for Sinch inbound SMS with Next.js include customer support chatbots, appointment reminders with confirmation replies, two-factor authentication (2FA), feedback collection systems, automated survey responses, and notification preference management.

How to Set Up Your Next.js Project for Sinch Webhooks

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 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:

bash
npx create-next-app@latest sinch-inbound-app --typescript --eslint --tailwind --app --src-dir --use-npm --import-alias "@/*"
  • --typescript: Enables TypeScript for type safety.
  • --eslint: Sets up ESLint for code linting.
  • --tailwind: Configures Tailwind CSS (optional, but common for styling).
  • --app: Uses the App Router (recommended for Next.js 13+, required for route handlers).
  • --src-dir: Creates a src directory for application code organization.
  • --use-npm: Uses npm instead of yarn as the package manager.
  • --import-alias "@/*": Sets up path aliases for cleaner imports.

Navigate into your new project directory:

bash
cd sinch-inbound-app

2. Install Dependencies: For robust implementation with validation and database support, install Prisma and Zod:

bash
npm install prisma@^5.0.0 zod@^3.0.0 @prisma/client@^5.0.0
  • prisma: The ORM CLI tool for database schema management and migrations (v5.x).
  • @prisma/client: The Prisma Client for type-safe database queries (v5.x).
  • zod: Schema validation library for runtime type checking (v3.x).

Note: The base create-next-app command with --typescript already includes necessary development types like @types/node, @types/react, etc.

3. Initialize Prisma (Optional Database Step): If you plan to store messages in a database (recommended for production):

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.
  • Replace postgresql with mysql, sqlite, sqlserver, or mongodb 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:

bash
touch .env.local

Add the following variables. You'll get the Sinch values later.

plaintext
# .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. Ensure 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 with HMAC-SHA256 hashing. Generate a strong random string (32+ characters) 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 (US, EU, etc.). These aren't strictly required for receiving only, but are crucial for signature verification logic or sending replies.

Important Security Note: Add .env.local to your .gitignore file to avoid committing secrets to version control. The create-next-app template usually does this automatically. Verify this before committing code.

5. Project Structure: The relevant structure within the src directory will be:

text
src/
├── app/
│   ├── api/
│   │   └── sinch/
│   │       └── inbound/
│   │           └── route.ts  <-- Your 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.

Implementing the Core Webhook Handler

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:

bash
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:

typescript
// src/app/api/sinch/inbound/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { verifySinchSignature } from '@/lib/sinchUtils'; // You'll create this util later

export async function POST(request: NextRequest) {
    console.log('Received request on /api/sinch/inbound');

    // 1. --- Verify Signature (CRITICAL SECURITY STEP) ---
    // Read 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');
        // Don't 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 {
        // 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:
    • Read the raw request body using request.text(). This is essential because signature verification hashes the raw, unparsed body.
    • Retrieve the signature header (e.g., x-sinch-signature). Crucially, verify the correct header name in the official Sinch documentation.
    • Call verifySinchSignature (you'll create this in the next section) to compare the expected signature with the one received.
    • If verification fails, return 401 Unauthorized immediately. Never process unverified webhooks.
  • JSON Parsing: Only after successful signature verification do you parse the rawBody into a JavaScript object using JSON.parse().
  • Payload Processing: 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: Return a NextResponse with a 200 OK status. This tells Sinch you've successfully received the webhook. Do this promptly; complex processing should ideally happen asynchronously (e.g., via a job queue) to avoid timeouts.

Building the API Layer

Refine the webhook handler with proper validation and structure.

1. Request Validation with Zod: Validate the structure of the incoming payload even after signature verification.

Define a Zod schema matching the expected Sinch inbound SMS payload.

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

Use this schema in your API route:

typescript
// 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 ---
    const validationResult = sinchInboundSmsSchema.safeParse(payloadJson);

    if (!validationResult.success) {
        console.error('Invalid payload structure:', validationResult.error.errors);
        // Log the specific validation errors for debugging
        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) ---

    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. 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):

bash
# 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, 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).

Integrating with Sinch (Configuration)

This involves configuring your Sinch account to send webhooks to your Next.js application.

  1. 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 for SINCH_SERVICE_PLAN_ID and SINCH_API_TOKEN. Also set SINCH_REGION_URL accordingly.
  2. 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:

        1. Start your Next.js dev server: npm run dev (usually runs on port 3000).
        2. Open another terminal and run ngrok:
          bash
          ngrok http 3000
        3. ngrok will display forwarding URLs. Copy the HTTPS URL (e.g., https://random-string.ngrok-free.app).
        4. Append your API route path: https://random-string.ngrok-free.app/api/sinch/inbound
        5. Paste this full HTTPS URL into the Sinch Callback URL field.
      • For Production:

        1. Deploy your Next.js application (e.g., to Vercel, Netlify, AWS).
        2. Get your production domain URL (e.g., https://your-app-name.vercel.app).
        3. Append your API route path: https://your-app-name.vercel.app/api/sinch/inbound
        4. Paste this URL into the Sinch Callback URL field.
  3. 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.
  4. 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 by verifySinchSignature.
  • 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.

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 or 503 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 generic 500 status, logging the stack trace.

2. Logging:

  • Development: console.log and console.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.

Example Refinement with try...catch:

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

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
// 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 unique id 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 that body 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.

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({
    // 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:

bash
# 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 in node_modules/.prisma/client.
  • prisma migrate dev: Creates a new SQL migration file in prisma/migrations, applies it to your development database, and ensures the database schema matches schema.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.

typescript
// 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 });
}

Frequently Asked Questions About Sinch Inbound SMS with Next.js

How do I receive SMS messages in Next.js with Sinch?

Receive SMS messages in Next.js by creating an API Route at /app/api/sinch/inbound/route.ts that handles POST requests from Sinch webhooks. Configure your Sinch account to send webhooks to your deployed Next.js application URL. The webhook handler should verify the HMAC-SHA256 signature, parse the JSON payload, process the message, and return HTTP 200 OK to acknowledge receipt.

What is HMAC-SHA256 signature verification for Sinch webhooks?

HMAC-SHA256 signature verification ensures webhook requests genuinely come from Sinch and haven't been tampered with. Sinch signs each webhook request using a shared secret and includes the signature in the x-sinch-signature header (verify exact header name in docs). Your Next.js handler computes the expected signature from the raw request body and your secret, then compares it with the received signature. Reject any requests with invalid signatures to prevent security vulnerabilities.

How do I validate Sinch webhook payloads in Next.js?

Validate Sinch webhook payloads using Zod schema validation. Define a Zod schema matching the expected Sinch inbound SMS structure (id, from, to, type, body, received_at, etc.). Use schema.safeParse() to validate the parsed JSON payload after signature verification. Return HTTP 400 Bad Request with detailed validation errors for invalid payloads. This provides runtime type safety beyond TypeScript's compile-time checks.

Can I test Sinch webhooks locally with Next.js?

Yes, test Sinch webhooks locally using ngrok to expose your local Next.js development server (running on port 3000) to the internet. Run ngrok http 3000, copy the HTTPS URL, and configure it as your Sinch webhook callback URL. Sinch will send real SMS webhook requests to your local machine. Remember to use the ngrok HTTPS URL, not HTTP, as most webhook providers require HTTPS.

How do I prevent duplicate SMS processing with Sinch webhooks?

Prevent duplicate SMS processing by implementing idempotency using Sinch's unique message ID (payload.id). Before creating a new database record, check if a message with that ID already exists using prisma.inboundSms.findUnique(). If it exists, skip processing but still return HTTP 200 OK to acknowledge receipt and stop Sinch retries. This handles cases where Sinch retries webhook delivery due to timeouts or failures.

What database schema should I use for storing Sinch SMS messages?

Use a Prisma schema with the Sinch message ID as the primary key for idempotency. Include fields: id (String, @id @unique), sinchFrom/sinchTo (String), type (String), body (String?), receivedAt (DateTime), sentAt (DateTime?), operatorId (String?), clientReference (String?), createdAt (DateTime), updatedAt (DateTime). Use @map() to handle reserved SQL keywords like "from" and "to". Mark optional fields based on Sinch documentation.

How do I handle Sinch webhook retries in Next.js?

Handle Sinch webhook retries by responding with HTTP 200 OK promptly (within a few seconds) and implementing idempotent processing. Sinch retries webhooks if your endpoint doesn't respond or returns 5xx errors. Use the message ID to detect duplicates and skip reprocessing. For temporary errors (database timeout), you might return 500 to trigger a retry, but for permanent errors or after successful processing, always return 200 to prevent infinite retries.

What's the difference between Next.js Pages Router and App Router for webhooks?

Use the App Router (Next.js 13+) for webhook handlers as it provides native route handlers via route.ts files with dedicated HTTP method exports (POST, GET, etc.). The Pages Router requires API routes in /pages/api/ with a single handler function checking req.method. App Router offers better TypeScript support, cleaner code organization, and is the recommended approach for new Next.js applications as of v14/v15.

How do I deploy a Next.js Sinch webhook handler to production?

Deploy to platforms like Vercel, Netlify, or AWS that support Next.js. After deployment, get your production HTTPS URL (e.g., https://your-app.vercel.app), append your API route path (/api/sinch/inbound), and configure this full URL in your Sinch dashboard as the webhook callback URL. Ensure environment variables (DATABASE_URL, SINCH_CALLBACK_SECRET, etc.) are configured in your deployment platform's settings. Test with a real SMS to your Sinch number.

Can I send automated replies to inbound SMS with Sinch and Next.js?

Yes, send automated replies by making an outbound SMS API call to Sinch within your webhook handler. Use the Sinch Node.js SDK or REST API with your SINCH_SERVICE_PLAN_ID and SINCH_API_TOKEN to send a message back to the sender (payload.from). Implement this in the business logic section after storing the inbound message. Be mindful of response times – consider using a job queue for complex reply logic to avoid webhook timeouts.

Summary: Building Production-Ready Inbound SMS with Sinch and Next.js

You've now built a complete inbound SMS webhook system using Sinch and Next.js with proper security, validation, and data persistence. This production-ready implementation handles webhook signature verification, payload validation, idempotent message storage, and graceful error handling.

Key Takeaways for Sinch Next.js SMS Webhooks:

  • Use Next.js App Router (v14/v15) with API Routes at /app/api/sinch/inbound/route.ts for webhook handlers
  • Implement HMAC-SHA256 signature verification to validate webhook authenticity before processing
  • Use Zod v3.x schemas for runtime payload validation beyond TypeScript compile-time checks
  • Store messages in PostgreSQL using Prisma v5.x with the Sinch message ID as primary key for idempotency
  • Check for existing messages before creating records to handle Sinch webhook retries gracefully
  • Respond with HTTP 200 OK quickly (within seconds) to acknowledge receipt and prevent retries
  • Use ngrok for local webhook testing during development with HTTPS tunnels
  • Configure environment variables (SINCH_CALLBACK_SECRET, DATABASE_URL) securely
  • Deploy to Vercel, Netlify, or AWS and update Sinch webhook callback URL to production HTTPS endpoint

For more SMS integration tutorials, explore guides on sending SMS with Sinch and Next.js, implementing OTP authentication, or building marketing campaigns with the Sinch SMS API.