code examples

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

Sinch SMS Delivery Status and Callbacks with Next.js and Supabase

Learn how to implement SMS delivery tracking in a Next.js application using the Sinch SMS API and Supabase database with real-time delivery status updates through webhooks.

Sinch SMS Delivery Status and Callbacks with Next.js and Supabase

This comprehensive guide demonstrates how to implement SMS delivery tracking in a Next.js application using the Sinch SMS API and Supabase database. Learn how to send SMS messages programmatically, receive real-time delivery status updates through Sinch webhooks, and maintain a complete message audit trail for compliance and analytics.

Business Context: SMS delivery status tracking is essential for time-sensitive communications such as order confirmations requiring proof of delivery, appointment reminders where non-delivery necessitates alternative contact methods, security alerts (2FA codes, fraud warnings) where delivery failures must trigger immediate fallback authentication, and transactional notifications where failed deliveries indicate invalid contact information requiring database updates.

Project Goal: Create a production-ready system that sends SMS messages programmatically, tracks their delivery status through carrier network callbacks, and maintains a persistent audit trail in Supabase for compliance and analytics.

Core Problems Solved:

  • Programmatic SMS sending with proper authentication and error handling
  • Real-time delivery confirmation beyond initial API submission success (distinguishing between "accepted by API" and "delivered to handset")
  • Webhook endpoint implementation to receive asynchronous carrier delivery receipts (DLRs)
  • Persistent storage of message lifecycle data for auditing, retry logic, and customer service inquiries
  • Integration with Supabase for serverless database access with Row Level Security (RLS)

Technologies Used:

  • Node.js 18+: JavaScript runtime (LTS version 20.x or current version recommended, see Node.js release schedule)
  • Next.js 14+ (App Router): React framework with API Route Handlers (app/api/*/route.ts convention) for serverless webhook endpoints
  • Sinch SMS API: Multi-region REST API for SMS/MMS. Uses Service Plan ID and API Token authentication (Sinch SMS API docs)
  • Sinch Node.js SDK (@sinch/sdk-core): Official SDK version 1.x supporting Promises and TypeScript definitions (GitHub)
  • Supabase: PostgreSQL database with built-in REST API, real-time subscriptions, and Row Level Security. Uses @supabase/supabase-js client library
  • ngrok or Vercel: For exposing local webhook endpoints during development or deploying to production

System Architecture:

  1. Your Next.js application sends an SMS request to the Sinch SMS API using your Service Plan ID and API Token (Bearer authentication).
  2. Sinch validates the request and returns a batch_id immediately (HTTP 201 Created), then relays the message to the carrier network (SMSC).
  3. The application stores the outgoing message details in Supabase (messages table) with initial status Queued (code 400).
  4. The carrier network attempts delivery to the recipient's handset.
  5. The carrier sends a Delivery Receipt (DLR) back to Sinch with status codes indicating outcome (e.g., Delivered code 0, Failed code varies).
  6. Sinch formats the DLR and triggers an HTTP POST webhook to your pre-configured callback URL with a JSON payload containing batch_id, status, code, recipient, and timestamp.
  7. Your Next.js API Route Handler validates the webhook, updates the message status in Supabase, and returns HTTP 204 No Content to acknowledge receipt.

Timing Expectations: Webhook delivery typically occurs within 5-30 seconds for successful deliveries in major markets (US/EU). Carrier delays can extend this to several minutes. Failed deliveries (Rejected, status code 402+) are usually reported within seconds. Message expiry (code 406) can take hours if the handset is powered off. Sinch retries failed webhook deliveries with exponential backoff: 5s, 10s, 20s, 40s, 80s, doubling up to 81,920s (~22.75 hours) (Sinch webhook retry documentation).

Final Outcome: A Next.js application capable of sending SMS messages, receiving delivery status webhooks, and maintaining a complete message audit trail in Supabase for compliance and analytics.

Prerequisites:

  • Node.js 18.x or later (20.x LTS recommended): Download Node.js
  • Sinch API Account: Free tier available at Sinch Customer Dashboard. Provides $2 USD trial credit.
  • Sinch Virtual Phone Number: Purchase SMS-enabled number from dashboard (costs vary: ~$1-2/month US local number, ~$0.01-0.10 per SMS sent depending on destination). Available in US, EU, CA, AU, BR regions.
  • Supabase Project: Free tier sufficient for development. Create at Supabase Dashboard. Includes PostgreSQL database, authentication, and 500MB storage.
  • ngrok (for local testing): Free account allows HTTPS tunnels. Download ngrok
  • Text Editor: VS Code, WebStorm, or similar
  • Personal Phone Number: To receive test SMS messages (E.164 format, e.g., +14155551234)

Estimated Costs (Development):

  • Sinch virtual number: $1-2/month
  • Sinch SMS outbound (US): ~$0.0075-0.01 per message
  • Supabase: Free tier (up to 500MB database, 2GB bandwidth/month)
  • Total monthly estimate for testing: <$5 USD

1. Supabase Database Setup for SMS Delivery Tracking

Configure your Supabase project and create the messages table before writing application code.

1.1 Create Supabase Project

  1. Navigate to Supabase Dashboard and sign in
  2. Click New Project
  3. Enter project name (e.g., sinch-sms-tracker), database password, and select region closest to your users
  4. Wait 2-3 minutes for provisioning
  5. Copy your Project URL and anon/public API key from Settings > API (format: https://xxxxx.supabase.co and eyJhbGc...)

1.2 Create Messages Table for SMS Status Tracking

Execute this SQL in the Supabase SQL Editor (Tools > SQL Editor > New query):

sql
-- Table to store SMS messages and their delivery status
CREATE TABLE messages (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  batch_id TEXT UNIQUE NOT NULL, -- Sinch batch ID for correlation
  recipient TEXT NOT NULL, -- E.164 phone number
  sender TEXT NOT NULL, -- Your Sinch virtual number
  body TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'Queued', -- Sinch status: Queued, Dispatched, Delivered, Failed, etc.
  status_code INTEGER NOT NULL DEFAULT 400, -- Sinch delivery receipt error code
  client_reference TEXT, -- Optional client identifier for correlation
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  delivered_at TIMESTAMPTZ, -- Timestamp from carrier delivery receipt
  error_message TEXT -- Populated if status indicates failure
);

-- Index for fast batch_id lookups (webhook updates)
CREATE INDEX idx_messages_batch_id ON messages(batch_id);

-- Index for querying by recipient
CREATE INDEX idx_messages_recipient ON messages(recipient);

-- Index for status queries (filter failed messages, etc.)
CREATE INDEX idx_messages_status ON messages(status);

-- Trigger to auto-update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER update_messages_updated_at
    BEFORE UPDATE ON messages
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at_column();

-- Enable Row Level Security (RLS) - important for production
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;

-- Policy: Allow all operations for authenticated users (adjust for your auth strategy)
-- For development without authentication, use: CREATE POLICY "Allow all" ON messages FOR ALL USING (true);
CREATE POLICY "Enable all access for authenticated users"
  ON messages
  FOR ALL
  USING (auth.role() = 'authenticated');

-- For service role access (server-side operations), no RLS restrictions apply automatically

Schema Explanation:

  • batch_id: Unique identifier returned by Sinch API, used to correlate webhook callbacks with sent messages
  • status: Human-readable delivery status from Sinch delivery report statuses: Queued (code 400), Dispatched (401), Delivered (0), Rejected (402), Failed (varies), Expired (406), Cancelled (407), Aborted (403-405, 408, 410-418), Unknown (no DLR received)
  • status_code: Numeric code from Sinch providing detailed failure reasons (see Sinch error codes)
  • RLS policies prevent unauthorized access; adjust based on your authentication strategy

2. Sinch Account and API Setup

Obtain credentials and configure webhook URL in the Sinch dashboard.

2.1 Sign Up and Get Credentials

  1. Create Account: Visit Sinch Customer Dashboard and sign up (free tier includes $2 credit)
  2. Get Service Plan ID and API Token:
    • Navigate to SMS > APIs in the left sidebar
    • Copy your Service Plan ID (format: abc123def456..., ~32 hex characters)
    • Click Show next to API Token to reveal and copy it (format: long alphanumeric string)
    • Note: These credentials authenticate via Bearer token (Authorization: Bearer YOUR_API_TOKEN). The Service Plan ID is included in API endpoint paths.
  3. Region Confirmation: Check your region (US/EU/CA/AU/BR) displayed on the APIs page. This determines your API base URL:
    • US: https://us.sms.api.sinch.com
    • EU: https://eu.sms.api.sinch.com
    • CA: https://ca.sms.api.sinch.com
    • AU: https://au.sms.api.sinch.com
    • BR: https://br.sms.api.sinch.com

2.2 Purchase Virtual Number

  1. Navigate to Numbers > Buy numbers in the dashboard
  2. Select country (e.g., United States)
  3. Ensure SMS capability is enabled (checkbox)
  4. Choose number type:
    • Local numbers: Tied to specific area codes, better delivery rates for regional messaging (~$1-2/month)
    • Toll-free numbers: Recognized nationwide, suitable for customer service (~$2-5/month)
    • Mobile numbers: Required in some countries (e.g., Singapore, India) for SMS delivery
  5. Click Get Number to purchase (confirm pricing)
  6. Copy the purchased number in E.164 format (e.g., +12065551234)

Number Selection Guidance: For US/Canada, local numbers provide best deliverability and cost-effectiveness. Toll-free numbers are subject to additional carrier filtering and may require 10DLC registration for business messaging. International deployments should verify local regulations regarding sender ID types.

2.3 Configure Webhook URL for Delivery Reports (After Deployment)

Important: Complete this step after deploying your Next.js application (Section 5) to obtain the public webhook URL.

  1. In Sinch Dashboard, go to SMS > APIs
  2. Scroll to Callback URLs section
  3. Enter your webhook URL in Default callback URL field:
    • Development (ngrok): https://abc123.ngrok.io/api/webhooks/sinch/delivery
    • Production (Vercel): https://your-app.vercel.app/api/webhooks/sinch/delivery
  4. (Optional) Configure authentication:
    • Basic Auth: Include credentials in URL (https://user:pass@your-domain.com/api/webhooks/sinch/delivery). Secure but credentials visible in logs.
    • HMAC Signing: Contact Sinch account manager to enable. Provides cryptographic verification without embedding credentials.
    • Custom Headers: Request custom header injection (e.g., X-Sinch-Signature: secret_value) for additional validation.
  5. Click Save

Security Note: Webhook URLs are called by Sinch servers, not your users. Implement request validation (see Section 6.2) to prevent unauthorized POST requests from spoofed sources.


3. Next.js Project Setup

Initialize a new Next.js 14+ project with TypeScript and install dependencies.

3.1 Create Next.js Application

bash
# Create Next.js app with TypeScript and App Router (select Yes for TypeScript, ESLint, App Router)
npx create-next-app@latest sinch-sms-nextjs-supabase
cd sinch-sms-nextjs-supabase

# Verify structure includes app/ directory (App Router)
ls -la app/

Expected output shows app/ directory with layout.tsx, page.tsx, and globals.css.

3.2 Install Dependencies

bash
# Install Sinch SDK (official Node.js SDK supporting SMS API)
npm install @sinch/sdk-core@^1.1.0

# Install Supabase client (official JavaScript client for PostgreSQL access)
npm install @supabase/supabase-js@^2.39.0

# Install environment variable loader (dotenv-flow supports .env.local for Next.js)
npm install dotenv

# Install types for Node.js (if not already present)
npm install --save-dev @types/node

Version Specifications:

  • @sinch/sdk-core@^1.1.0: Stable release with SMS batches, delivery reports, and webhook helpers (changelog)
  • @supabase/supabase-js@^2.39.0: Latest stable with improved TypeScript definitions and connection pooling
  • Breaking changes are unlikely in minor versions (semantic versioning), but pin major versions for production

3.3 Configure Environment Variables

Create .env.local in project root (automatically excluded from Git in Next.js):

bash
# .env.local

# Sinch Credentials (from Sinch Dashboard > SMS > APIs)
SINCH_SERVICE_PLAN_ID=your_service_plan_id_here
SINCH_API_TOKEN=your_api_token_here
SINCH_REGION=us  # Options: us, eu, ca, au, br

# Sinch Phone Number (E.164 format, purchased virtual number)
SINCH_FROM_NUMBER=+12065551234

# Test Recipient (your personal phone for testing, E.164 format)
TEST_TO_NUMBER=+14155559876

# Supabase Credentials (from Supabase Dashboard > Settings > API)
NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGc...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGc...  # Optional: for admin operations bypassing RLS

# Webhook Security (optional, for HMAC validation or custom header verification)
WEBHOOK_SECRET=your_random_secret_string_here

E.164 Format Explanation: International standard for phone numbers: +[country_code][subscriber_number]. Examples: US +14155551234 (country code 1), UK +447700123456 (country code 44), Singapore +6598765432 (country code 65). Omit leading zeros, spaces, or special characters. ITU-T E.164 standard.

Security Best Practices:

  • Never commit .env.local to version control
  • Use different credentials for development/staging/production environments
  • Rotate API tokens periodically (Sinch dashboard allows generating new tokens)
  • For production, use platform environment variables (Vercel Environment Variables, AWS Secrets Manager, etc.)

3.4 Project Structure

sinch-sms-nextjs-supabase/ ├── app/ │ ├── api/ │ │ ├── sms/ │ │ │ └── send/ │ │ │ └── route.ts # API endpoint to send SMS │ │ └── webhooks/ │ │ └── sinch/ │ │ └── delivery/ │ │ └── route.ts # Webhook handler for delivery receipts │ ├── layout.tsx │ ├── page.tsx # Homepage with send SMS form │ └── globals.css ├── lib/ │ ├── sinch.ts # Sinch client initialization │ └── supabase.ts # Supabase client initialization ├── types/ │ └── sinch.ts # TypeScript interfaces for Sinch payloads ├── .env.local # Environment variables (DO NOT COMMIT) ├── .gitignore ├── next.config.js ├── package.json ├── tsconfig.json └── README.md

4. Implementation: Sending SMS and Handling Delivery Webhooks

4.1 Initialize Sinch Client (lib/sinch.ts)

typescript
// lib/sinch.ts
import { SinchClient } from '@sinch/sdk-core';

if (!process.env.SINCH_SERVICE_PLAN_ID || !process.env.SINCH_API_TOKEN) {
  throw new Error('Missing Sinch credentials: SINCH_SERVICE_PLAN_ID and SINCH_API_TOKEN must be set in .env.local');
}

const region = (process.env.SINCH_REGION || 'us') as 'us' | 'eu' | 'ca' | 'au' | 'br';

/**
 * Sinch SMS API client singleton
 * Authenticated with Service Plan ID and API Token (Bearer auth)
 * Region determines API base URL
 */
export const sinchClient = new SinchClient({
  servicePlanId: process.env.SINCH_SERVICE_PLAN_ID,
  apiToken: process.env.SINCH_API_TOKEN,
  region: region,
});

/**
 * Helper function to send SMS via Sinch API
 * @param to - Recipient phone number in E.164 format
 * @param body - Message text (max 1600 chars for single concatenated SMS)
 * @param clientReference - Optional client identifier for tracking
 * @returns Sinch API response with batch_id
 */
export async function sendSMS(to: string, body: string, clientReference?: string) {
  if (!process.env.SINCH_FROM_NUMBER) {
    throw new Error('Missing SINCH_FROM_NUMBER environment variable');
  }

  try {
    const response = await sinchClient.sms.batches.send({
      sendSMSRequestBody: {
        to: [to],
        from: process.env.SINCH_FROM_NUMBER,
        body: body,
        type: 'mt_text', // Mobile-terminated text message
        delivery_report: 'per_recipient', // Request per-recipient delivery reports (triggers webhook for each status change)
        client_reference: clientReference,
      },
    });

    return response;
  } catch (error: any) {
    console.error('Sinch API Error:', error.response?.data || error.message);
    throw error;
  }
}

Code Explanation:

  • SinchClient constructor accepts servicePlanId, apiToken, and region for authentication and routing
  • delivery_report: 'per_recipient' enables webhook callbacks for each status change (alternative: 'per_recipient_final' for final status only, or 'summary' for batch-level report without webhooks)
  • type: 'mt_text' specifies standard SMS (alternatives: 'mt_binary' for binary data, 'mt_media' for MMS)
  • Error handling captures Sinch API errors (rate limits, invalid numbers, insufficient credit) for upstream handling

4.2 Initialize Supabase Client (lib/supabase.ts)

typescript
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js';

if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
  throw new Error('Missing Supabase credentials: NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY must be set');
}

/**
 * Supabase client for client-side operations (respects RLS policies)
 * Uses anon key, suitable for browser and server components
 */
export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
);

/**
 * Supabase admin client for server-side operations (bypasses RLS)
 * Use only in API routes and server components, never expose to client
 * Optional: requires SUPABASE_SERVICE_ROLE_KEY
 */
export const supabaseAdmin = process.env.SUPABASE_SERVICE_ROLE_KEY
  ? createClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL,
      process.env.SUPABASE_SERVICE_ROLE_KEY,
      {
        auth: {
          autoRefreshToken: false,
          persistSession: false,
        },
      }
    )
  : null;

Client Types:

  • supabase (anon key): Respects Row Level Security policies, safe for client-side use
  • supabaseAdmin (service role key): Bypasses RLS, full database access, use only server-side for administrative operations like webhook processing

4.3 TypeScript Interfaces (types/sinch.ts)

typescript
// types/sinch.ts

/**
 * Sinch delivery report webhook payload (per-recipient report)
 * Docs: https://developers.sinch.com/docs/sms/api-reference/sms/tag/Delivery-reports#delivery-report-webhook
 */
export interface SinchDeliveryReportWebhook {
  type: 'recipient_delivery_report_sms' | 'recipient_delivery_report_mms';
  batch_id: string;
  recipient: string; // E.164 phone number
  code: number; // Sinch status code (0 = delivered, 400 = queued, 401 = dispatched, 402+ = errors)
  status: 'Queued' | 'Dispatched' | 'Aborted' | 'Rejected' | 'Delivered' | 'Failed' | 'Expired' | 'Cancelled' | 'Deleted' | 'Unknown';
  at: string; // ISO 8601 timestamp when DLR was created in Sinch system
  operator_status_at?: string; // ISO 8601 timestamp from carrier (may be missing)
  client_reference?: string;
  applied_originator?: string; // Actual sender ID used (if default originator pool configured)
  encoding?: 'GSM' | 'UNICODE';
  number_of_message_parts?: number;
  operator?: string; // MCC/MNC if available
}

/**
 * Sinch batch delivery report webhook (summary or full report)
 * Triggered when delivery_report is 'summary' or 'full' instead of 'per_recipient'
 */
export interface SinchBatchDeliveryReportWebhook {
  type: 'delivery_report_sms' | 'delivery_report_mms';
  batch_id: string;
  total_message_count: number;
  statuses: Array<{
    code: number;
    status: string;
    count: number;
    recipients?: string[]; // Present only in 'full' reports
  }>;
  client_reference?: string;
}

/**
 * Database schema for messages table
 */
export interface MessageRecord {
  id: string; // UUID
  batch_id: string;
  recipient: string;
  sender: string;
  body: string;
  status: string;
  status_code: number;
  client_reference?: string;
  created_at: string;
  updated_at: string;
  delivered_at?: string;
  error_message?: string;
}

4.4 API Route: Send SMS (app/api/sms/send/route.ts)

typescript
// app/api/sms/send/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sendSMS } from '@/lib/sinch';
import { supabase } from '@/lib/supabase';

/**
 * POST /api/sms/send
 * Send SMS via Sinch and store in Supabase
 * Request body: { to: string, body: string, clientReference?: string }
 */
export async function POST(request: NextRequest) {
  try {
    const { to, body, clientReference } = await request.json();

    // Validate input
    if (!to || !body) {
      return NextResponse.json(
        { error: 'Missing required fields: to, body' },
        { status: 400 }
      );
    }

    // Validate E.164 format (basic regex)
    const e164Regex = /^\+[1-9]\d{1,14}$/;
    if (!e164Regex.test(to)) {
      return NextResponse.json(
        { error: 'Invalid phone number format. Must be E.164 (e.g., +14155551234)' },
        { status: 400 }
      );
    }

    // Send SMS via Sinch
    const sinchResponse = await sendSMS(to, body, clientReference);

    // Store in Supabase with initial status
    const { data, error } = await supabase
      .from('messages')
      .insert({
        batch_id: sinchResponse.id,
        recipient: to,
        sender: process.env.SINCH_FROM_NUMBER!,
        body: body,
        status: 'Queued', // Initial status
        status_code: 400, // Sinch code for Queued
        client_reference: clientReference || null,
      })
      .select()
      .single();

    if (error) {
      console.error('Supabase insert error:', error);
      // SMS sent but DB write failed - log for manual reconciliation
      return NextResponse.json(
        {
          success: true,
          batch_id: sinchResponse.id,
          warning: 'SMS sent but failed to log in database',
        },
        { status: 201 }
      );
    }

    return NextResponse.json(
      {
        success: true,
        batch_id: sinchResponse.id,
        message_id: data.id,
        status: data.status,
      },
      { status: 201 }
    );
  } catch (error: any) {
    console.error('SMS send error:', error);
    return NextResponse.json(
      { error: 'Failed to send SMS', details: error.message },
      { status: 500 }
    );
  }
}

Key Implementation Details:

  • E.164 validation prevents API errors from malformed numbers
  • Initial status Queued (code 400) reflects message acceptance by Sinch, not carrier delivery
  • Partial failure handling: if Sinch succeeds but database write fails, return success with warning for operational visibility

4.5 API Route: Webhook Handler for SMS Delivery Status (app/api/webhooks/sinch/delivery/route.ts)

typescript
// app/api/webhooks/sinch/delivery/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin, supabase } from '@/lib/supabase';
import { SinchDeliveryReportWebhook } from '@/types/sinch';

/**
 * POST /api/webhooks/sinch/delivery
 * Handle Sinch delivery report webhooks
 * Updates message status in Supabase based on carrier delivery receipts
 *
 * Webhook payload docs: https://developers.sinch.com/docs/sms/api-reference/sms/tag/Delivery-reports#delivery-report-webhook
 */
export async function POST(request: NextRequest) {
  try {
    // Parse webhook payload
    const payload: SinchDeliveryReportWebhook = await request.json();

    console.log('📥 Delivery report webhook received:', {
      batch_id: payload.batch_id,
      recipient: payload.recipient,
      status: payload.status,
      code: payload.code,
      timestamp: payload.at,
    });

    // Optional: Validate webhook authenticity (see Section 6.2)
    // const isValid = validateWebhookSignature(request, payload);
    // if (!isValid) {
    //   console.error('❌ Invalid webhook signature');
    //   return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    // }

    // Determine error message based on status code
    const errorMessage = getErrorMessage(payload.code, payload.status);

    // Update message status in Supabase
    // Use supabaseAdmin to bypass RLS (webhook is server-to-server, not user-initiated)
    const client = supabaseAdmin || supabase;
    const { data, error } = await client
      .from('messages')
      .update({
        status: payload.status,
        status_code: payload.code,
        delivered_at: payload.operator_status_at || payload.at,
        error_message: errorMessage,
        // updated_at is auto-updated by trigger
      })
      .eq('batch_id', payload.batch_id)
      .select();

    if (error) {
      console.error('❌ Supabase update error:', error);
      // Return 500 to trigger Sinch retry (webhook will be resent)
      return NextResponse.json(
        { error: 'Database update failed' },
        { status: 500 }
      );
    }

    if (!data || data.length === 0) {
      console.warn('⚠️ No matching message found for batch_id:', payload.batch_id);
      // Return 204 anyway to prevent endless retries (message may not exist in DB)
      return new NextResponse(null, { status: 204 });
    }

    console.log('✅ Message status updated:', data[0]);

    // Respond with 204 No Content to acknowledge receipt
    // This stops Sinch from retrying the webhook
    return new NextResponse(null, { status: 204 });
  } catch (error: any) {
    console.error('❌ Webhook processing error:', error);
    // Return 500 to trigger retry (transient errors)
    return NextResponse.json(
      { error: 'Webhook processing failed' },
      { status: 500 }
    );
  }
}

/**
 * Map Sinch status codes to human-readable error messages
 * Docs: https://developers.sinch.com/docs/sms/api-reference/sms/tag/Delivery-reports#delivery-report-error-codes
 */
function getErrorMessage(code: number, status: string): string | null {
  if (code === 0 || status === 'Delivered') return null;

  const errorMap: Record<number, string> = {
    400: 'Message queued within Sinch system',
    401: 'Message dispatched to carrier',
    402: 'Message unroutable (SMSC rejected, check recipient number)',
    403: 'Internal error (unexpected failure, contact Sinch support)',
    404: 'Temporary delivery failure (carrier issue, retry recommended)',
    405: 'Unmatched parameter (parameterization error, check message template)',
    406: 'Message expired before reaching carrier (expiry time too short)',
    407: 'Message cancelled by sender before dispatch',
    408: 'Internal reject (SMSC rejected, check sender ID / number provisioning)',
    410: 'Unmatched default originator (no default sender configured for recipient)',
    411: 'Exceeded parts limit (message too long, reduce content or increase limit)',
    412: 'Unprovisioned region (account not enabled for destination country)',
    413: 'Account blocked (insufficient credits or compliance hold, contact billing)',
    414: 'Bad media URL (MMS only, media unreachable or invalid format)',
    415: 'Delivery report rejected (MMS gateway/network rejected)',
    416: 'Delivery report not supported (handset/network does not support MMS)',
    417: 'Delivery report unreachable (network or subscriber unreachable)',
    418: 'Delivery report unrecognized (handset does not recognize content)',
  };

  return errorMap[code] || `Unknown error (code: ${code}, status: ${status})`;
}

Webhook Status Codes Summary:

CodeStatusTypeDescriptionAction Required
0DeliveredFinalMessage delivered to handsetNone (success)
400QueuedIntermediateQueued in Sinch systemNormal, wait for dispatch
401DispatchedIntermediateAccepted by carrier SMSCNormal, wait for delivery
402AbortedFinalUnroutable (invalid number)Validate recipient number, remove from list
403AbortedFinalInternal errorContact Sinch support if recurring
404AbortedFinalTemporary failureRetry with exponential backoff
406ExpiredFinalMessage expiredIncrease expiry time or retry
407CancelledFinalCancelled by senderExpected if cancel API called
413AbortedFinalAccount blockedCheck billing, add credits, or resolve compliance issue

Idempotency Considerations: Sinch may retry webhooks if they don't receive a 2xx response within timeout (~30 seconds). Use batch_id as idempotency key to prevent duplicate processing. The Supabase UPDATE operation is idempotent by design (updating same record multiple times with same values has no adverse effect). For critical operations (e.g., triggering refunds, sending emails), implement explicit idempotency checks (e.g., check if status already equals incoming status before processing).


5. Testing SMS Delivery Status Webhooks

5.1 Local Development with ngrok

  1. Start Next.js Development Server:

    bash
    npm run dev
    # Server runs at http://localhost:3000
  2. Expose Localhost with ngrok:

    Open a new terminal and run:

    bash
    ngrok http 3000

    Copy the HTTPS forwarding URL (e.g., https://abc123.ngrok.io)

  3. Configure Sinch Webhook URL:

    • Go to Sinch Dashboard > SMS > APIs > Callback URLs
    • Set Default callback URL to: https://abc123.ngrok.io/api/webhooks/sinch/delivery
    • Click Save
  4. Test SMS Sending:

    bash
    curl -X POST http://localhost:3000/api/sms/send \
      -H "Content-Type: application/json" \
      -d '{
        "to": "+14155559876",
        "body": "Test SMS from Next.js + Sinch + Supabase",
        "clientReference": "test-001"
      }'
  5. Verify Results:

    • Terminal: Check npm run dev logs for "📥 Delivery report webhook received"

    • Phone: Receive SMS within 5-30 seconds

    • Supabase: Query messages table in SQL Editor:

      sql
      SELECT * FROM messages ORDER BY created_at DESC LIMIT 5;
    • ngrok Dashboard: Inspect webhook requests at http://127.0.0.1:4040 (shows full request/response)

Expected Webhook Payload Example:

json
{
  "type": "recipient_delivery_report_sms",
  "batch_id": "01HZQE7CVXXXXXXXXXX",
  "recipient": "+14155559876",
  "code": 0,
  "status": "Delivered",
  "at": "2025-01-15T10:23:45.678Z",
  "operator_status_at": "2025-01-15T10:23:44.123Z",
  "client_reference": "test-001"
}

5.2 Deployment to Vercel

  1. Initialize Git Repository:

    bash
    git init
    git add .
    git commit -m "Initial commit: Sinch SMS with Next.js and Supabase"
  2. Push to GitHub/GitLab:

    Create a repository on GitHub and push:

    bash
    git remote add origin https://github.com/your-username/sinch-sms-nextjs-supabase.git
    git branch -M main
    git push -u origin main
  3. Deploy to Vercel:

    • Go to Vercel Dashboard
    • Click Add New > Project
    • Import your GitHub repository
    • Configure environment variables (copy from .env.local):
      • SINCH_SERVICE_PLAN_ID
      • SINCH_API_TOKEN
      • SINCH_REGION
      • SINCH_FROM_NUMBER
      • NEXT_PUBLIC_SUPABASE_URL
      • NEXT_PUBLIC_SUPABASE_ANON_KEY
      • SUPABASE_SERVICE_ROLE_KEY (optional)
      • WEBHOOK_SECRET (optional)
    • Click Deploy
  4. Update Sinch Webhook URL:

    • Copy your Vercel deployment URL (e.g., https://your-app.vercel.app)
    • Update Sinch Dashboard callback URL to: https://your-app.vercel.app/api/webhooks/sinch/delivery
  5. Test Production Deployment:

    bash
    curl -X POST https://your-app.vercel.app/api/sms/send \
      -H "Content-Type: application/json" \
      -d '{
        "to": "+14155559876",
        "body": "Production test",
        "clientReference": "prod-001"
      }'

6. Security and Production Considerations

6.1 Environment Variable Security

  • Never commit .env.local to Git (Next.js automatically excludes it via .gitignore)
  • Use platform secrets management in production:
    • Vercel: Environment Variables (encrypted at rest)
    • AWS: Secrets Manager or Parameter Store
    • Google Cloud: Secret Manager
    • Azure: Key Vault
  • Rotate credentials periodically (quarterly recommended for API tokens)
  • Use separate credentials for development, staging, and production environments

6.2 Webhook Signature Verification (CRITICAL for Production)

Without signature verification, any attacker can send fake delivery reports to your webhook endpoint. Implement one of these methods:

Option A: HMAC Signature Verification (Recommended)

Contact your Sinch account manager to enable HMAC signing. Sinch will include a signature in the webhook request that you can verify:

typescript
// Add to app/api/webhooks/sinch/delivery/route.ts
import crypto from 'crypto';

function validateWebhookSignature(request: NextRequest, payload: any): boolean {
  const signature = request.headers.get('x-sinch-signature');
  const secret = process.env.WEBHOOK_SECRET!;

  if (!signature || !secret) {
    return false;
  }

  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(JSON.stringify(payload))
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

Option B: Custom Header Authentication

Request Sinch to inject a custom header (e.g., X-Webhook-Token) and validate it:

typescript
function validateWebhookToken(request: NextRequest): boolean {
  const token = request.headers.get('x-webhook-token');
  return token === process.env.WEBHOOK_SECRET;
}

Option C: IP Allowlisting

Restrict webhook endpoint to Sinch IP ranges (contact Sinch support for current IPs). Configure in next.config.js with middleware or use Vercel Firewall.

6.3 Rate Limiting

Protect your webhook endpoint from abuse:

bash
npm install @upstash/ratelimit @upstash/redis
typescript
// lib/ratelimit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

export const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(100, '1 m'), // 100 requests per minute
});

// In webhook route:
const identifier = request.headers.get('x-forwarded-for') || 'anonymous';
const { success } = await ratelimit.limit(identifier);
if (!success) {
  return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
}

6.4 Error Handling and Monitoring

Logging:

  • Use structured logging (e.g., Pino, Winston)
  • Log all webhook receipts with batch_id, status, and timestamp
  • Separate logs by severity (info, warn, error, critical)

Error Tracking:

  • Integrate Sentry for exception tracking:

    bash
    npm install @sentry/nextjs
    npx @sentry/wizard@latest -i nextjs

Monitoring:

  • Track webhook processing time (should be <200ms to avoid Sinch timeouts)
  • Alert on failed database writes (indicates data loss)
  • Monitor Sinch API rate limits (default: 600 requests/minute for SMS)

6.5 Retry Logic for Failed SMS

Implement retry with exponential backoff for transient Sinch API errors (network timeouts, 5xx responses):

typescript
// lib/retry.ts
import { sendSMS } from './sinch';

export async function sendSMSWithRetry(
  to: string,
  body: string,
  clientReference?: string,
  maxRetries = 3
) {
  let lastError;
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await sendSMS(to, body, clientReference);
    } catch (error: any) {
      lastError = error;
      const isRetryable = error.response?.status >= 500 || error.code === 'ECONNRESET';
      if (!isRetryable || attempt === maxRetries) {
        throw error;
      }
      const delay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
      console.log(`Retry ${attempt}/${maxRetries} after ${delay}ms`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
  throw lastError;
}

7. Troubleshooting Common Issues

7.1 SMS Not Sending

Symptom: API returns 201 but no SMS received.

Debugging Steps:

  1. Verify Recipient Number Format: Must be E.164 (+14155551234). No spaces, dashes, or parentheses.
  2. Check Sinch Dashboard Logs: SMS > Logs shows all sent messages, delivery status, and error codes.
  3. Verify Sender Number: Ensure SINCH_FROM_NUMBER matches a purchased number in your account.
  4. Check Account Balance: Insufficient credits will cause 413 errors. Add credits in Billing section.
  5. Regional Restrictions: Some countries block SMS from certain sender ID types (e.g., alphanumeric sender IDs blocked in US/Canada). Use purchased number instead.
  6. Carrier Filtering: Spam filters may block messages with certain content (e.g., "click here", shortened URLs). Test with plain text.

7.2 Webhook Not Received

Symptom: SMS delivered to phone but database status remains "Queued".

Debugging Steps:

  1. Verify Webhook URL Configuration: Check Sinch Dashboard > SMS > APIs > Callback URLs. Must be publicly accessible HTTPS URL.

  2. Test Webhook Endpoint Manually:

    bash
    curl -X POST https://your-app.vercel.app/api/webhooks/sinch/delivery \
      -H "Content-Type: application/json" \
      -d '{
        "type": "recipient_delivery_report_sms",
        "batch_id": "test-batch-123",
        "recipient": "+14155551234",
        "code": 0,
        "status": "Delivered",
        "at": "2025-01-15T10:00:00Z"
      }'

    Should return 204 No Content.

  3. Check ngrok Connection: If using ngrok, ensure tunnel is active (ngrok http 3000 running).

  4. Review Webhook Logs: Check Next.js console (npm run dev) for incoming requests and errors.

  5. Inspect Sinch Retry Attempts: Sinch retries webhooks on 5xx/timeout. Check if retries are accumulating (indicates endpoint issues).

  6. Verify Delivery Report Setting: When sending SMS, ensure delivery_report: 'per_recipient' is set (see Section 4.1).

7.3 Database Write Failures

Symptom: Webhook received but Supabase update fails.

Common Causes:

  1. RLS Policy Mismatch: If using supabase client (anon key) in webhook handler, RLS may block updates. Use supabaseAdmin instead.

  2. Missing batch_id: If message was sent outside this application, batch_id won't exist in database. Add defensive check:

    typescript
    if (!data || data.length === 0) {
      console.warn('No matching message found, ignoring webhook');
      return new NextResponse(null, { status: 204 }); // Acknowledge to prevent retries
    }
  3. Network Timeout: Supabase queries exceeding 30s cause Sinch to retry. Optimize queries with indexes (see Section 1.2).

7.4 Rate Limit Errors

Symptom: Sinch API returns 429 Too Many Requests.

Solution: Sinch enforces rate limits (default 600 requests/minute for SMS API). Implement client-side throttling:

typescript
// lib/throttle.ts
import pThrottle from 'p-throttle';

const throttle = pThrottle({
  limit: 10, // 10 requests
  interval: 1000, // per second
});

export const sendSMSThrottled = throttle(sendSMS);

Contact Sinch support to increase rate limits for high-volume use cases.


8. Advanced Features

8.1 Querying Delivery Reports via API

Fetch delivery reports programmatically without webhooks:

typescript
// lib/sinch.ts
export async function getDeliveryReport(batchId: string) {
  const report = await sinchClient.sms.deliveryReports.get({
    batch_id: batchId,
    type: 'full', // 'summary' for counts only
  });
  return report;
}

Use case: Reconcile missed webhooks or generate reports for specific batches.

8.2 Scheduled Messages with Supabase Edge Functions

Implement send-at-time functionality using Supabase Edge Functions triggered by cron:

sql
-- Add send_at column
ALTER TABLE messages ADD COLUMN send_at TIMESTAMPTZ;

-- Create Edge Function to poll pending messages
-- See https://supabase.com/docs/guides/functions/schedule-functions

8.3 Two-Way Messaging (Inbound SMS)

Handle incoming SMS replies by configuring inbound webhook in Sinch Dashboard. Learn more about implementing two-way SMS messaging with Sinch:

typescript
// app/api/webhooks/sinch/inbound/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const payload = await request.json();
  console.log('📩 Inbound SMS:', payload);

  // Store in Supabase or trigger automation
  await supabase.from('inbound_messages').insert({
    from: payload.from,
    to: payload.to,
    body: payload.body,
    received_at: payload.received_at,
  });

  return new NextResponse(null, { status: 204 });
}

Configure in Sinch Dashboard: SMS > APIs > Inbound callback URL: https://your-app.vercel.app/api/webhooks/sinch/inbound

8.4 Message Templates and Parameterization

Reduce code duplication with parameterized templates:

typescript
const response = await sinchClient.sms.batches.send({
  sendSMSRequestBody: {
    to: ['+14155551234', '+14155555678'],
    from: process.env.SINCH_FROM_NUMBER!,
    body: 'Hello ${name}, your order #${order_id} has shipped!',
    parameters: {
      name: {
        '+14155551234': 'Alice',
        '+14155555678': 'Bob',
        default: 'Customer',
      },
      order_id: {
        '+14155551234': 'ORD-001',
        '+14155555678': 'ORD-002',
        default: 'N/A',
      },
    },
  },
});

9. Conclusion

You have successfully built a production-ready Next.js application that:

  • Sends SMS messages via Sinch SMS API with proper error handling
  • Receives and processes delivery status webhooks in real-time
  • Stores complete message audit trails in Supabase for compliance and analytics
  • Implements security best practices (environment variables, webhook validation, rate limiting)

Key Learnings:

  • Webhooks decouple synchronous API requests from asynchronous delivery confirmations, enabling scalable architectures
  • Idempotency is critical for webhook handlers due to automatic retry mechanisms
  • Proper status code handling (2xx acknowledgment) prevents webhook storms
  • Database indexes on batch_id are essential for fast webhook processing at scale

Production Checklist:

  • Webhook signature verification implemented (HMAC or custom header)
  • Rate limiting configured (Upstash Ratelimit or similar)
  • Error tracking integrated (Sentry)
  • Monitoring alerts for failed webhooks and database writes
  • Separate credentials for production environment
  • Supabase RLS policies configured for your authentication strategy
  • Message retention policy defined (archive/delete old messages)
  • Sender ID registration completed for regulated countries (10DLC for US, A2P for India, etc.)

Next Steps:

  • Implement retry logic for failed SMS sends (exponential backoff)
  • Add analytics dashboard querying Supabase (success rate, delivery time distribution)
  • Integrate inbound SMS handling for two-way conversations
  • Implement message templates with Supabase-stored content
  • Add MMS support for images/videos (type: 'mt_media')
  • Explore Sinch Conversation API for multi-channel messaging (SMS, WhatsApp, RCS)

Related Resources: