code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / Sinch

How to Build SMS Consent Management with Next.js and Sinch: TCPA Compliance Guide 2025

Build a TCPA-compliant SMS consent management system with Next.js and Sinch. Step-by-step tutorial for handling SMS opt-in, opt-out, and STOP keywords with webhook integration, 10DLC registration, and automated compliance. Includes complete API route code examples.

SMS Consent Management with Next.js and Sinch: Complete TCPA Compliance Guide (2024-2025)

Build a TCPA-compliant SMS consent management system using Next.js and Sinch. This step-by-step tutorial shows you how to implement SMS opt-in and opt-out handling, process consent keywords (SUBSCRIBE, STOP, HELP), configure secure webhooks with Basic Auth, and meet the new FCC opt-out requirements effective April 11, 2025. Perfect for developers building SMS marketing campaigns with Next.js 15 and Sinch Node SDK 1.2.1. Includes complete API route code examples, 10DLC registration guidance, and automated consent tracking.

Proper SMS consent management is legally required under TCPA (US) and GDPR (EU) regulations. Failing to handle opt-ins and opt-outs correctly results in fines of $500–$1,500 per text message, potential class-action lawsuits, and severe brand reputation damage. In 2020 alone, companies paid over $120 million in TCPA settlements for SMS marketing violations.

This guide walks you through building a robust SMS consent management system using Next.js and the Sinch SMS API. Create a Next.js application with an API endpoint that listens for incoming SMS messages sent to your Sinch number. This endpoint processes standard keywords like SUBSCRIBE and STOP, manages user participation in a Sinch group, and sends appropriate confirmation messages back to users.

Project Goal: Create a Next.js API endpoint that securely handles incoming Sinch SMS webhooks to manage user opt-ins (SUBSCRIBE) and opt-outs (STOP) for an SMS marketing group, leveraging the Sinch Node SDK for communication and group management.

Technologies Used:

  • Next.js: A React framework enabling server-side rendering and API routes, perfect for handling webhooks and server-side logic within a single project. Latest version: 15.5 (as of December 2024), with React 19 support and stable Node.js middleware runtime.
  • Sinch SMS API & Node SDK: Provides the necessary tools to send/receive SMS messages and manage contact groups programmatically. Sinch Node SDK version 1.2.1 (GA release, December 2024). Install via npm install @sinch/sdk-core @sinch/sms.
  • Node.js: The runtime environment for Next.js and the Sinch SDK. Requires Node.js v18.17.0 or later (v20.x or v22.x LTS recommended for 2024–2025).

System Architecture:

User sends SMS → Sinch receives → Webhook to Next.js API → Process keyword → Update Sinch Group → Send confirmation SMS → User receives reply

Prerequisites:

  • Node.js (v18.17.0 or later recommended; v20.x or v22.x LTS preferred for 2024–2025) and npm/yarn installed.
  • A Sinch account with access to the SMS API.
  • A provisioned phone number within your Sinch account capable of sending and receiving SMS.
  • Sinch API Credentials:
    • Project ID
    • API Key ID
    • API Key Secret
    • Service Plan ID associated with your SMS service.
  • Familiarity with JavaScript/TypeScript and Next.js fundamentals.
  • Crucially for US Traffic (A2P 10DLC): A registered Brand and an approved Campaign within the Sinch platform/TCR (The Campaign Registry). All unregistered traffic will be blocked after December 1, 2024. This guide focuses on implementing consent logic, but you must complete 10DLC registration separately to legally send A2P marketing messages in the US. Registration typically takes 3–5 business days and involves two steps: (1) Brand registration with business information (legal name, EIN/Tax ID, address), and (2) Campaign registration describing your use case. You cannot register directly with TCR – work through Sinch as your messaging service provider. See the Sinch 10DLC Documentation for details.
  • TCPA Compliance (US): New FCC opt-out rules take effect April 11, 2025. Honor opt-out requests within 10 business days (reduced from 30 days), and accept any reasonable opt-out method. Keywords like STOP, QUIT, END, REVOKE, OPT OUT, CANCEL, and UNSUBSCRIBE via reply text constitute per se reasonable opt-out methods. Retain documentation of opt-out requests for at least 4 years (TCPA statute of limitations).

By the end of this guide, you'll have a deployable Next.js application capable of handling SMS consent keywords reliably and securely.

Step 1: Setting Up Your Next.js Project for SMS Webhooks

Create a new Next.js project and install the Sinch SDK to handle SMS webhooks and consent management.

  1. Create a new Next.js App: Open your terminal and run:

    bash
    npx create-next-app@latest sinch-consent-manager
    cd sinch-consent-manager

    Follow the prompts (TypeScript recommended: Yes, ESLint: Yes, Tailwind CSS: No (or Yes if desired), src/ directory: Yes, App Router: No (we'll use Pages Router for simpler API routes in this example), import alias: default).

  2. Install Sinch SDK: Install the core Sinch SDK and the SMS module:

    bash
    npm install @sinch/sdk-core @sinch/sms
  3. Set up Environment Variables: Create a file named .env.local in the root of your project. Never commit this file to version control. Add your Sinch credentials and configuration. These variables store sensitive API credentials (to authenticate with Sinch), your phone number (the sender identity), group name (to organize subscribers), and webhook security credentials (to verify incoming requests from Sinch).

    env
    # Sinch API Credentials & Configuration
    SINCH_KEY_ID=YOUR_SINCH_KEY_ID
    SINCH_KEY_SECRET=YOUR_SINCH_KEY_SECRET
    SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID
    SINCH_SERVICE_PLAN_ID=YOUR_SINCH_SERVICE_PLAN_ID
    SINCH_NUMBER=YOUR_PROVISIONED_SINCH_PHONE_NUMBER # e.g., +12025550181
    
    # Application Configuration
    SINCH_MARKETING_GROUP_NAME="Marketing Subscribers" # Name for the Sinch Group
    
    # Webhook Security (Basic Auth Example)
    # Generate strong random username/password
    WEBHOOK_USERNAME=your_secure_username
    WEBHOOK_PASSWORD=your_secure_password
    • SINCH_KEY_ID, SINCH_KEY_SECRET, SINCH_PROJECT_ID: Find these in your Sinch Customer Dashboard under your Account > API Credentials or Project settings.
    • SINCH_SERVICE_PLAN_ID: Locate this ID associated with your SMS API service plan in the Sinch Dashboard (often under SMS > APIs).
    • SINCH_NUMBER: The full E.164 formatted phone number you've provisioned in Sinch for this campaign.
    • SINCH_MARKETING_GROUP_NAME: A descriptive name for the group we'll create in Sinch to manage subscribers.
    • WEBHOOK_USERNAME, WEBHOOK_PASSWORD: Credentials for securing your webhook endpoint using Basic Authentication. Choose strong, unique values.
  4. Project Structure: Your src/pages/api/ directory is where the core webhook logic will reside. The rest follows a standard Next.js structure.

Step 2: Implementing the SMS Webhook API Route

Create a Next.js API route at /pages/api/webhooks/sinch-sms.js to receive and process incoming SMS consent keywords from Sinch webhooks.

  1. Create the API Route File: Create a new file: src/pages/api/webhooks/sinch-sms.js

  2. Implement the Webhook Handler: Add the following code to src/pages/api/webhooks/sinch-sms.js. This code initializes the Sinch client, handles Basic Auth, parses incoming messages, manages group membership, and sends replies.

    javascript
    import { SinchClient } from '@sinch/sdk-core';
    import { SmsRegion } from '@sinch/sms'; // Adjust region if needed (e.g., SmsRegion.EU)
    
    // --- Configuration ---
    const sinchClient = new SinchClient({
      projectId: process.env.SINCH_PROJECT_ID,
      keyId: process.env.SINCH_KEY_ID,
      keySecret: process.env.SINCH_KEY_SECRET,
      // Optional: Specify region if not US default
      // smsRegion: SmsRegion.US,
    });
    
    const servicePlanId = process.env.SINCH_SERVICE_PLAN_ID;
    const sinchNumber = process.env.SINCH_NUMBER;
    const groupName = process.env.SINCH_MARKETING_GROUP_NAME || 'Default Marketing Group';
    const webhookUser = process.env.WEBHOOK_USERNAME;
    const webhookPass = process.env.WEBHOOK_PASSWORD;
    
    // --- Helper Functions ---
    
    /**
     * Finds or creates a Sinch group by name.
     * Returns the Group ID.
     */
    async function findOrCreateGroup(name) {
      try {
        const { groups } = await sinchClient.sms.groups.list();
        let group = groups.find((g) => g.name === name);
    
        if (!group) {
          console.log(`Group "${name}" not found, creating…`);
          const newGroup = await sinchClient.sms.groups.create({
            createGroupRequestBody: { name: name },
          });
          console.log(`Group "${name}" created with ID: ${newGroup.id}`);
          return newGroup.id;
        } else {
          console.log(`Found group "${name}" with ID: ${group.id}`);
          return group.id;
        }
      } catch (error) {
        console.error('Error finding or creating Sinch group:', error?.response?.data || error.message);
        throw new Error('Could not find or create Sinch group.');
      }
    }
    
    /**
     * Sends an SMS message using the Sinch SDK.
     */
    async function sendSms(to, message) {
      try {
        console.log(`Sending SMS to ${to}: "${message}"`);
        // Note: Verify the method name and parameters below against the current Sinch SDK documentation.
        await sinchClient.sms.batches.send({
          sendBatchSmsRequestBody: {
            to: [to],
            from: sinchNumber,
            body: message,
            delivery_report: 'none', // Adjust if delivery reports are needed
          },
          // Pass servicePlanId if not using default project settings
          // servicePlanId: servicePlanId
        });
        console.log(`SMS successfully sent to ${to}`);
      } catch (error) {
        console.error(`Error sending SMS to ${to}:`, error?.response?.data || error.message);
        // Implement retry logic here if needed (see Section 4 discussion)
      }
    }
    
    /**
     * Basic Authentication Middleware
     * Verifies webhook requests from Sinch using credentials.
     */
    function handleBasicAuth(req, res) {
      if (!webhookUser || !webhookPass) {
        console.warn('Webhook basic auth credentials not set. Endpoint is unsecured.');
        return true; // Skip auth if not configured (not recommended for production)
      }
    
      const authHeader = req.headers.authorization;
      if (!authHeader || !authHeader.startsWith('Basic ')) {
        console.warn('Missing or invalid Authorization header');
        res.setHeader('WWW-Authenticate', 'Basic realm="Secure Area"');
        res.status(401).json({ error: 'Authorization Required' });
        return false;
      }
    
      const base64Credentials = authHeader.split(' ')[1];
      const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii');
      const [username, password] = credentials.split(':');
    
      if (username === webhookUser && password === webhookPass) {
        return true; // Authenticated
      } else {
        console.warn('Invalid Basic Auth credentials provided');
        res.setHeader('WWW-Authenticate', 'Basic realm="Secure Area"');
        res.status(401).json({ error: 'Invalid Credentials' });
        return false;
      }
    }
    
    
    // --- API Handler ---
    export default async function handler(req, res) {
      // 1. Only accept POST requests
      if (req.method !== 'POST') {
        console.log(`Received non-POST request: ${req.method}`);
        res.setHeader('Allow', ['POST']);
        return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
      }
    
      // 2. Authenticate the request (Basic Auth)
      if (!handleBasicAuth(req, res)) {
        return; // Response already sent by handleBasicAuth
      }
    
      // 3. Parse the incoming webhook payload
      const incomingSms = req.body;
      console.log('Received Sinch Webhook:', JSON.stringify(incomingSms, null, 2));
    
      // Basic validation of expected payload structure
      if (!incomingSms || typeof incomingSms !== 'object' || !incomingSms.from || !incomingSms.body) {
          console.error('Invalid or missing webhook payload structure:', incomingSms);
          return res.status(400).json({ error: 'Invalid payload structure' });
      }
    
      const fromNumber = incomingSms.from; // Sender's phone number
      const messageBody = incomingSms.body.trim().toUpperCase(); // Normalize keyword
    
      try {
        // 4. Find or Create the Target Group
        const groupId = await findOrCreateGroup(groupName);
    
        // 5. Process Keywords
        if (messageBody === 'SUBSCRIBE' || messageBody === 'JOIN' || messageBody === 'START') {
          console.log(`Processing SUBSCRIBE request from ${fromNumber} for group ${groupId}`);
          // Add number to the group
          await sinchClient.sms.groups.replaceMembers({
            groupId: groupId,
            addPhoneNumbersRequest: { add: [fromNumber] }, // Use 'add' to avoid removing others
          });
          console.log(`Added ${fromNumber} to group ${groupId}`);
    
          // Send confirmation message
          const reply = `Thanks for subscribing to ${groupName}! Msg frequency varies. Msg&Data rates may apply. Reply HELP for help, STOP to cancel.`;
          await sendSms(fromNumber, reply);
    
        } else if (messageBody === 'STOP' || messageBody === 'UNSUBSCRIBE' || messageBody === 'CANCEL' || messageBody === 'END' || messageBody === 'QUIT') {
          console.log(`Processing STOP request from ${fromNumber} for group ${groupId}`);
          // Remove number from the group
          await sinchClient.sms.groups.removeMember({
            groupId: groupId,
            phoneNumber: fromNumber,
          });
           console.log(`Removed ${fromNumber} from group ${groupId}`);
    
          // Send confirmation message
          const reply = `You have unsubscribed from ${groupName} and will no longer receive messages. Reply SUBSCRIBE to rejoin.`;
          await sendSms(fromNumber, reply);
    
        } else if (messageBody === 'HELP') {
             console.log(`Processing HELP request from ${fromNumber}`);
             // Send help message
             const reply = `${groupName}: For help, contact support@example.com or reply STOP to unsubscribe. Msg&Data rates may apply.`; // Customize help info
             await sendSms(fromNumber, reply);
    
        } else {
          // Optional: Handle unknown keywords or general messages
          console.log(`Received unknown keyword "${messageBody}" from ${fromNumber}`);
          // Decide if you want to reply to unknown messages. Sometimes it's better not to.
          // Example reply:
          // const reply = `Sorry, I didn't understand "${incomingSms.body}". Reply SUBSCRIBE to join or STOP to unsubscribe.`;
          // await sendSms(fromNumber, reply);
        }
    
        // 6. Respond to Sinch Platform
        // Sinch expects a 2xx status code to acknowledge receipt of the webhook.
        return res.status(200).json({ status: 'ok', message: 'Webhook processed' });
    
      } catch (error) {
        console.error('Error processing webhook:', error);
        // Respond with an error status code to indicate failure.
        // Avoid sending detailed internal errors back in the response for security.
        return res.status(500).json({ error: 'Internal Server Error' });
      }
    }

    Code Explanation:

    • Initialization: Sets up the SinchClient using credentials from environment variables. Defines constants for configuration.
    • findOrCreateGroup: Checks if the group defined by SINCH_MARKETING_GROUP_NAME exists. If not, creates it using sinchClient.sms.groups.create. Returns the group ID. Includes error logging for troubleshooting.
    • sendSms: Sends an SMS using sinchClient.sms.batches.send. Takes the recipient number and message body. Logs errors during sending.
    • handleBasicAuth: Implements Basic Authentication checking based on WEBHOOK_USERNAME and WEBHOOK_PASSWORD. Sends appropriate 401 responses if auth fails or is missing. Returns true on success, false on failure.
    • handler (Main Logic):
      • Checks for POST method.
      • Calls handleBasicAuth to secure the endpoint.
      • Parses the req.body (Sinch sends JSON). Includes basic payload validation. Production applications should use more rigorous validation with libraries like zod or joi to strictly enforce the expected schema.
      • Extracts the sender's number (from) and message body (body). Normalizes the body to uppercase for case-insensitive keyword matching.
      • Calls findOrCreateGroup to get the relevant group ID.
      • Uses if/else if blocks to check for SUBSCRIBE/JOIN/START, STOP/UNSUBSCRIBE/etc., and HELP keywords.
      • Subscribe Logic: Uses sinchClient.sms.groups.replaceMembers with the add property to add the number to the group without affecting other members. The Sinch Groups API handles duplicate adds gracefully. Sends a confirmation SMS.
      • Stop Logic: Uses sinchClient.sms.groups.removeMember to remove the specific number. The API handles unknown removes gracefully. Sends an opt-out confirmation SMS.
      • Help Logic: Sends a pre-defined help message.
      • Unknown Keywords: Logs them. You can optionally add a reply here.
      • Response: Sends a 200 OK response back to Sinch to acknowledge successful processing. Sends 500 Internal Server Error if processing fails within the try…catch block.

Step 3: Configuring Sinch Webhooks for Inbound SMS

Configure your Sinch Service Plan to send SMS webhooks to your deployed Next.js API endpoint with Basic Authentication.

  1. Deploy Your Application: Deploy your Next.js application to get a publicly accessible URL. Use hosting providers like Vercel, Netlify, or AWS Amplify.

    • Using Vercel (Example):
      • Push your code to a Git repository (GitHub, GitLab, Bitbucket).
      • Connect your repository to Vercel.
      • Configure Environment Variables in the Vercel project settings (copy from your .env.local).
      • Deploy. Vercel provides a production URL (e.g., https://your-app-name.vercel.app).
    • Your webhook URL will be: https://your-app-name.vercel.app/api/webhooks/sinch-sms
  2. Configure Sinch Webhook:

    • Log in to your Sinch Customer Dashboard.
    • Navigate to SMS → APIs.
    • Find your Service Plan ID (the one specified in .env.local) and click on it or its associated settings/edit icon.
    • Look for a section related to Callback URLs or Webhooks.
    • In the field for Incoming Messages (MO) or a similar label, enter the full URL of your deployed API route: https://your-app-name.vercel.app/api/webhooks/sinch-sms
    • Configure Authentication: Find the settings for webhook authentication. Select Basic Authentication.
      • Enter the Username defined in your .env.local (WEBHOOK_USERNAME).
      • Enter the Password defined in your .env.local (WEBHOOK_PASSWORD).
    • Save the configuration.

    Dashboard navigation might vary slightly. Consult the official Sinch documentation for the most up-to-date instructions on configuring SMS webhooks.

Step 4: Implementing Error Handling and Webhook Retries

  • Error Handling: The API route includes try…catch blocks around the main processing logic and Sinch SDK calls. Errors are logged to the console (which Vercel or your hosting provider typically captures). A generic 500 Internal Server Error is sent back to Sinch upon failure, preventing leakage of internal details.
  • Logging: We use console.log for informational messages and console.error for errors. For production, integrate a dedicated logging service (like Logtail, Datadog, Sentry) for better aggregation, searching, and alerting.
  • Retries:
    • Sinch Webhook Retries: Sinch typically retries sending webhooks if it doesn't receive a 2xx response within a timeout period. Consult the official Sinch documentation for their webhook retry behavior. Ensure your endpoint responds quickly (even with an error code).
    • Sinch API Call Retries: The current code doesn't implement explicit retries for sendSms or group operations. For critical operations, wrap Sinch SDK calls in a retry mechanism (e.g., using libraries like async-retry) with exponential backoff, especially for transient network errors or temporary Sinch API issues (like 5xx errors). Be cautious about retrying operations that modify state. Implement idempotency keys or careful state checking when retrying write operations. Retrying groups.list (read operation) is generally safe, whereas retrying groups.addMember (write operation) could potentially lead to duplicate actions if the initial request succeeded but the response was lost.

While this guide uses Sinch Groups for simplicity, production systems often benefit from maintaining their own database to store subscriber information and consent status.

  • Benefits: Persistence independent of Sinch, ability to store additional user data, detailed consent history (timestamps, source), easier querying and segmentation.
  • Implementation (Example with Prisma):
    1. Install Prisma: npm install prisma @prisma/client --save-dev

    2. Initialize Prisma: npx prisma init --datasource-provider postgresql (or your preferred DB)

    3. Configure prisma/schema.prisma:

      prisma
      datasource db {
        provider = "postgresql"
        url      = env("DATABASE_URL")
      }
      
      generator client {
        provider = "prisma-client-js"
      }
      
      model Subscriber {
        id            String   @id @default(cuid())
        phoneNumber   String   @unique // E.164 format
        isSubscribed  Boolean  @default(false)
        subscribedAt  DateTime?
        unsubscribedAt DateTime?
        createdAt     DateTime @default(now())
        updatedAt     DateTime @updatedAt
        // Add other fields as needed (e.g., source, name)
      }
    4. Set DATABASE_URL in .env.local.

    5. Run npx prisma db push to sync the schema.

    6. Modify the API route to import PrismaClient and update the database record alongside the Sinch Group operations:

      javascript
      import { PrismaClient } from '@prisma/client';
      const prisma = new PrismaClient();
      
      // In the SUBSCRIBE handler:
      await prisma.subscriber.upsert({
        where: { phoneNumber: fromNumber },
        update: { isSubscribed: true, subscribedAt: new Date() },
        create: { phoneNumber: fromNumber, isSubscribed: true, subscribedAt: new Date() }
      });
      
      // In the STOP handler:
      await prisma.subscriber.update({
        where: { phoneNumber: fromNumber },
        data: { isSubscribed: false, unsubscribedAt: new Date() }
      });

Using a database adds complexity but provides greater control and data richness. For this guide's scope, we rely solely on Sinch Groups.

Step 6: Securing Your SMS Webhooks with Authentication

  • Webhook Authentication: Basic Authentication is implemented in this guide. This ensures only Sinch can trigger your API route. Sinch also supports HMAC-SHA256 Signed Requests, which offer stronger security as credentials aren't sent directly with each request. HMAC authentication uses a hash-based message authentication code combining the request body, nonce, and timestamp, hashed with your secret key. To configure HMAC authentication, contact your Sinch account manager – it's managed at the account/service plan level and can be used alongside Basic Auth or OAuth 2.0. Upgrade to HMAC for production deployments with higher security requirements.

    HMAC Implementation Example:

    javascript
    import crypto from 'crypto';
    
    function verifyHmacSignature(req, secret) {
      const signature = req.headers['x-sinch-signature'];
      const timestamp = req.headers['x-sinch-timestamp'];
      const nonce = req.headers['x-sinch-nonce'];
      const body = JSON.stringify(req.body);
    
      const payload = `${timestamp}${nonce}${body}`;
      const expectedSignature = crypto
        .createHmac('sha256', secret)
        .update(payload)
        .digest('hex');
    
      return signature === expectedSignature;
    }
  • Input Validation: Basic checks ensure the payload exists and has from and body. More robust validation could use libraries like zod or joi to strictly enforce the expected Sinch payload schema.

  • HTTPS: Deploying via platforms like Vercel automatically enforces HTTPS, protecting data in transit.

  • Rate Limiting: High-traffic webhooks could be rate-limited to prevent abuse or resource exhaustion. Vercel offers built-in rate limiting on certain plans. Alternatively, use middleware with libraries like express-rate-limit (if using a custom server) or similar concepts in Next.js API routes.

  • Environment Variable Security: Keep .env.local secure and never commit it. Use your hosting provider's secret management features (like Vercel Environment Variables).

  • Least Privilege (Sinch API Key): Ensure the Sinch API Key has only the necessary permissions (SMS sending, group management) required for this application.

  • Keyword Case-Insensitivity: Handled by converting the incoming message body to uppercase (.toUpperCase()).
  • Whitespace: Handled using .trim() on the message body.
  • Multiple Keywords: The code handles common variations like SUBSCRIBE/JOIN/START and STOP/UNSUBSCRIBE/etc. Add more synonyms as needed.
  • International Numbers: The code assumes E.164 format (+ followed by country code and number) as provided by Sinch.
  • Character Encoding: Sinch webhooks typically use UTF-8 JSON, which Node.js handles correctly by default.
  • Duplicate Messages: Sinch might occasionally send duplicate webhooks. While the group add/remove operations are somewhat idempotent (adding an existing member or removing a non-existent member might not cause errors), consider adding logic to check the isSubscribed status in a database (if used) before performing the action.

Step 8: Optimizing Webhook Performance

  • Webhook Response Time: Respond to Sinch quickly (ideally under 2 seconds) to avoid timeouts and retries. The current logic is lightweight. If you add slow operations (complex DB queries, external API calls), defer them using background jobs/queues (e.g., Vercel Serverless Functions triggered by your webhook, or dedicated queue services).
  • Sinch Client Initialization: The SinchClient is initialized outside the handler function, so it's reused across requests within the same serverless function instance, improving performance.
  • Caching Group ID: The findOrCreateGroup call involves an API request. If performance is critical and the group rarely changes, cache the groupId in memory (with a short TTL) or a faster cache like Redis, but this adds complexity and is likely unnecessary for typical volumes.
  • Health Checks: Create a simple health check endpoint (e.g., src/pages/api/health.js) that returns 200 OK to verify the deployment is live.

    javascript
    export default function handler(req, res) {
      res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
    }
  • Logging: Centralized logging (Vercel Logs, Datadog, etc.) is crucial for monitoring webhook activity, errors, and execution flow.

  • Error Tracking: Integrate services like Sentry (npm install @sentry/nextjs, npx @sentry/wizard@latest -i nextjs) to capture, track, and get alerted on runtime errors in your API route.

  • Metrics: Track key metrics:

    • Webhook request count & latency
    • Number of SUBSCRIBE, STOP, HELP events processed
    • Sinch API call latency and error rates
    • (If using DB) Subscriber growth/churn rate
    • Hosting platforms often provide basic metrics. For more detail, use monitoring services.
  • Dashboards: Create dashboards (e.g., in Datadog, Grafana, or your logging provider) visualizing these metrics to understand traffic patterns and system health.

  • Alerting: Set up alerts (e.g., in Sentry or your monitoring tool) for:

    • High error rates (>1% of requests)
    • Sustained high latency (>2 seconds)
    • Failures in critical operations (e.g., findOrCreateGroup)

Step 10: Troubleshooting Common SMS Webhook Issues

  • Webhook Not Firing:
    • Verify the Callback URL in Sinch Dashboard is exactly correct and points to your deployed application URL.
    • Check your application logs (e.g., Vercel Logs) for any incoming requests or startup errors.
    • Ensure your deployment is healthy and accessible.
    • Check Sinch Dashboard for any delivery errors related to webhooks.
    • Ensure Basic Auth credentials match between .env.local/Vercel variables and Sinch configuration.
  • Authentication Errors (401 Unauthorized):
    • Double-check WEBHOOK_USERNAME and WEBHOOK_PASSWORD match exactly between your environment variables and the Sinch webhook configuration.
    • Ensure the Authorization: Basic … header is being sent correctly by Sinch (usually automatic if configured).
  • Sinch API Errors (Logged in Console):
    • 401 Unauthorized: Check SINCH_KEY_ID, SINCH_KEY_SECRET, SINCH_PROJECT_ID. Ensure the key is active and has SMS permissions.
    • 400 Bad Request: Often indicates an issue with the request body sent to Sinch (e.g., invalid phone number format, missing required fields). Check the error details logged. Could also be an incorrect SINCH_SERVICE_PLAN_ID.
    • 404 Not Found: Could mean the groupId is incorrect or the resource doesn't exist.
    • 5xx Server Error: Transient issue on Sinch's side. Consider implementing retries (see Section 4).
    • Check SinchClient Configuration: Ensure the correct smsRegion (e.g., SmsRegion.EU, SmsRegion.US) is configured when initializing the SinchClient if your service plan is not based in the default US region.
  • Consent Logic Not Working:
    • Check logs to see if keywords are being recognized correctly (case/trimming).
    • Verify the groupId being used is correct. Use the Sinch API (or dashboard, if available) to inspect the group members directly.
    • Ensure fromNumber is being parsed correctly from the webhook.
  • Caveats:
    • 10DLC Compliance (US): This code implements consent handling but does not fulfill 10DLC registration requirements. You must register your Brand and Campaign with The Campaign Registry (TCR) via Sinch for A2P SMS marketing in the US. Critical deadline: All unregistered traffic will be blocked after December 1, 2024. The registration process involves two steps: (1) Brand registration with business information (legal name, EIN/Tax ID, address), and (2) Campaign registration describing your use case. Approval typically takes 3–5 business days. You cannot register directly with TCR – work through Sinch as your messaging service provider. Failure to register results in blocked messages and potential fines ranging from $500–$1,500 per violation.
    • TCPA Opt-Out Requirements (US): New FCC rules effective April 11, 2025 require you to honor opt-out requests within 10 business days (reduced from up to 30 days). The keywords STOP, QUIT, END, REVOKE, OPT OUT, CANCEL, and UNSUBSCRIBE constitute per se reasonable opt-out methods. You may send one brief clarification message within 5 minutes of receiving an opt-out to confirm intent. Retain documentation of opt-out requests for at least 4 years (TCPA statute of limitations). This implementation processes opt-outs immediately, which exceeds the legal requirement.
    • Sinch Groups vs. Database: Sinch Groups are convenient but may have limitations in querying, scalability, or data richness compared to managing subscribers in your own database.
    • Idempotency: While basic group operations might be somewhat idempotent, ensure your logic handles potential duplicate webhook deliveries gracefully, especially if integrating with other systems or a database.
    • Rate Limits: Be aware of Sinch API rate limits. High-volume processing might require strategies to stay within limits.
    • Error Handling Granularity: The current error handling logs errors and returns 500. More granular error handling could provide better insights or trigger specific recovery actions.
    • SDK Verification: Verify the Sinch SDK method names and parameters used in the code examples against the latest official Sinch Node SDK v1.2.1 documentation to ensure they are current and accurate.
  • Deployment (Vercel Example):
    1. Ensure .env.local is in your .gitignore.
    2. Push code to your Git provider.
    3. Create a new Project on Vercel, importing your Git repository.
    4. Framework Preset: Should be detected as Next.js.
    5. Environment Variables: Copy all key-value pairs from .env.local into the Vercel Project Settings > Environment Variables section. Ensure they are available for the "Production" environment (and Preview/Development if needed).
    6. Deploy. Vercel builds and deploys the app, providing the production URL.
  • CI/CD:
    • Vercel automatically sets up CI/CD. Pushing to the main branch triggers a production deployment. Pushing to other branches or creating Pull Requests triggers preview deployments.
    • Automated Testing: Integrate testing (Unit, Integration) into your pipeline. Add an npm run test script to your package.json and configure Vercel (or your CI/CD tool like GitHub Actions) to run tests before deploying. Fail the build if tests fail.
    • Environment Configuration: Use Vercel's environment variable system to manage different configurations (e.g., separate Sinch credentials or group names for staging vs. production).
  • Rollbacks: Vercel maintains previous deployments. If a deployment introduces issues, instantly roll back to a previous working deployment via the Vercel dashboard.

Step 12: Testing SMS Opt-In and Opt-Out Flows

  1. Manual Verification:

    • Deploy the application and configure the Sinch webhook correctly (Section 3).
    • Using a test phone number (not the Sinch number itself), send SMS messages to your provisioned SINCH_NUMBER:
      • Send SUBSCRIBE (or Join, Start).
      • Expected: Receive the opt-in confirmation SMS. Check application logs for successful processing. Check Sinch Group members (via API or dashboard if possible) to confirm the number was added.
      • Send STOP (or Unsubscribe, Quit).
      • Expected: Receive the opt-out confirmation SMS. Check logs. Check Sinch Group to confirm removal.
      • Send HELP.
      • Expected: Receive the help message SMS. Check logs.
      • Send a random message (e.g., Hello).
      • Expected: No reply (based on current code). Check logs for "unknown keyword" message.
      • Send STOP again after unsubscribing.
      • Expected: Receive the opt-out confirmation SMS again (or handle gracefully). Check logs. Verify the number is still not in the group.
  2. Automated Testing Examples:

    Unit Test (Jest):

    javascript
    // __tests__/webhook.test.js
    import { createMocks } from 'node-mocks-http';
    import handler from '../src/pages/api/webhooks/sinch-sms';
    
    describe('/api/webhooks/sinch-sms', () => {
      it('rejects non-POST requests', async () => {
        const { req, res } = createMocks({ method: 'GET' });
        await handler(req, res);
        expect(res._getStatusCode()).toBe(405);
      });
    
      it('requires authentication', async () => {
        const { req, res } = createMocks({
          method: 'POST',
          body: { from: '+1234567890', body: 'SUBSCRIBE' }
        });
        await handler(req, res);
        expect(res._getStatusCode()).toBe(401);
      });
    });

    Integration Test (with mock Sinch client):

    javascript
    // __tests__/integration/consent.test.js
    import { testApiHandler } from 'next-test-api-route-handler';
    import * as handler from '@/pages/api/webhooks/sinch-sms';
    
    jest.mock('@sinch/sdk-core');
    
    describe('SMS Consent Flow', () => {
      it('processes SUBSCRIBE keyword correctly', async () => {
        await testApiHandler({
          handler,
          test: async ({ fetch }) => {
            const res = await fetch({
              method: 'POST',
              headers: {
                'Authorization': 'Basic ' + Buffer.from('test:test').toString('base64')
              },
              body: JSON.stringify({ from: '+1234567890', body: 'SUBSCRIBE' })
            });
            expect(res.status).toBe(200);
          }
        });
      });
    });

SMS consent management is the process of obtaining, storing, and honoring user preferences for receiving text messages. It's legally required under regulations like TCPA (Telephone Consumer Protection Act) in the US and GDPR in the EU. Obtain explicit written consent before sending marketing SMS messages, and honor opt-out requests promptly. Failure to comply results in fines ranging from $500 to $1,500 per violation, class-action lawsuits, and severe brand reputation damage.

What are the new TCPA opt-out requirements effective April 11, 2025?

The FCC's new opt-out rules, effective April 11, 2025, require businesses to honor opt-out requests within 10 business days (reduced from up to 30 days). Consumers can revoke consent using any reasonable method, and keywords like STOP, QUIT, END, REVOKE, OPT OUT, CANCEL, and UNSUBSCRIBE via reply text constitute per se reasonable opt-out methods. You may send one brief clarification message within 5 minutes of receiving an opt-out to confirm intent. Retain documentation of opt-out requests for at least 4 years (the TCPA statute of limitations).

What is A2P 10DLC registration and when is the deadline?

A2P (Application-to-Person) 10DLC registration is required for sending marketing SMS messages in the US using standard 10-digit phone numbers. Register your brand and campaign with The Campaign Registry (TCR) through your messaging provider (like Sinch). The critical deadline is December 1, 2024 – all unregistered traffic will be blocked after this date. Registration involves two steps: (1) Brand registration with business information (legal name, EIN/Tax ID, address), and (2) Campaign registration describing your use case. Approval typically takes 3–5 business days.

How do I implement SMS webhooks with Next.js and Sinch?

Create an API route (e.g., pages/api/webhooks/sinch-sms.js) that handles POST requests from Sinch. Initialize the Sinch Node SDK (version 1.2.1) with your API credentials, implement webhook authentication (Basic Auth or HMAC-SHA256), parse incoming SMS messages, process consent keywords (SUBSCRIBE, STOP, HELP), update Sinch Groups using the SDK, and send confirmation SMS messages. Deploy to Vercel or another hosting platform, then configure your Sinch Service Plan to send webhooks to your deployed URL.

What Node.js and Next.js versions do I need for Sinch integration?

You need Node.js v18.17.0 or later (v20.x or v22.x LTS recommended for 2024–2025) and Next.js 15.5 or later. Next.js 15 requires Node.js >= v18.17.0 minimum. The latest Sinch Node SDK (version 1.2.1, GA release December 2024) is compatible with these versions. Install the SDK using npm install @sinch/sdk-core @sinch/sms.

What's the difference between Basic Auth and HMAC for Sinch webhooks?

Basic Authentication sends credentials (username and password) with each webhook request in the Authorization header. HMAC-SHA256 (Hash-based Message Authentication Code) offers stronger security by creating a cryptographic signature using the request body, nonce, timestamp, and your secret key. HMAC doesn't send credentials directly with each request, making it more secure. To configure HMAC authentication with Sinch, contact your account manager – it's managed at the account/service plan level and can be used alongside Basic Auth or OAuth 2.0. Basic Auth is simpler to implement and suitable for getting started, while HMAC is recommended for production deployments with higher security requirements.

How do Sinch Groups work for managing SMS subscribers?

Sinch Groups are collections of phone numbers (in E.164 format) that you can use as targets when sending SMS messages. Create, update, and delete groups using the Sinch Node SDK. The Groups API handles duplicate adds and unknown removes gracefully (won't error if you add an existing member or remove a non-existent one). Groups use add and remove arrays to control membership, and additions are processed before deletions. While convenient for simple use cases, production systems often benefit from maintaining their own database for additional features like consent history, timestamps, and advanced querying.

What keywords must I support for SMS opt-out compliance?

For TCPA compliance, support standard opt-out keywords including STOP, QUIT, END, UNSUBSCRIBE, CANCEL, REVOKE, and OPT OUT (per se reasonable methods under the new FCC rules effective April 11, 2025). Best practice is also to support opt-in keywords like SUBSCRIBE, JOIN, and START, plus a HELP keyword to provide customer support information. Process these keywords case-insensitively and handle whitespace using .trim() and .toUpperCase() methods.

Can I test Sinch webhooks locally before deployment?

Yes, but Sinch requires a publicly accessible URL. For local testing, use ngrok or similar tunneling services to expose your local Next.js development server. Run ngrok http 3000 (matching your dev server port) to get a public HTTPS URL, then configure this URL in your Sinch Service Plan webhook settings. Use the ngrok web inspector (http://127.0.0.1:4040) to view incoming webhook requests and debug. Test webhook authentication, keyword processing, group operations, and SMS replies before deploying to production.

Local Testing Steps:

  1. Start your Next.js dev server: npm run dev
  2. In another terminal, run: ngrok http 3000
  3. Copy the HTTPS URL (e.g., https://abc123.ngrok.io)
  4. Configure this URL in Sinch Dashboard: https://abc123.ngrok.io/api/webhooks/sinch-sms
  5. Send test SMS messages to your Sinch number
  6. View requests in ngrok inspector and your console logs

While this guide uses Sinch Groups for simplicity, production systems typically benefit from their own database. A database provides persistence independent of Sinch, ability to store additional user data (name, source, preferences), detailed consent history with timestamps, easier querying and segmentation, and better compliance auditing. Use PostgreSQL with Prisma, MySQL, or MongoDB depending on your needs. Store at minimum: phone number, subscription status, subscribed/unsubscribed timestamps, and consent source. This data helps prove compliance during audits and supports TCPA's 4-year record retention requirement.