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.
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:
Your Next.js application sends an SMS request to the Sinch SMS API using your Service Plan ID and API Token (Bearer authentication).
Sinch validates the request and returns a batch_id immediately (HTTP 201 Created), then relays the message to the carrier network (SMSC).
The application stores the outgoing message details in Supabase (messages table) with initial status Queued (code 400).
The carrier network attempts delivery to the recipient's handset.
The carrier sends a Delivery Receipt (DLR) back to Sinch with status codes indicating outcome (e.g., Delivered code 0, Failed code varies).
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.
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.
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.
Enter project name (e.g., sinch-sms-tracker), database password, and select region closest to your users
Wait 2-3 minutes for provisioning
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 statusCREATE TABLE messages (
id UUID PRIMARY KEYDEFAULT gen_random_uuid(),
batch_id TEXT UNIQUENOT 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 NULLDEFAULT'Queued', -- Sinch status: Queued, Dispatched, Delivered, Failed, etc.
status_code INTEGERNOT NULLDEFAULT400, -- Sinch delivery receipt error code
client_reference TEXT, -- Optional client identifier for correlation
created_at TIMESTAMPTZ NOT NULLDEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULLDEFAULT 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 recipientCREATE 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 timestampCREATEOR REPLACE FUNCTION update_updated_at_column()
RETURNSTRIGGERAS $$
BEGIN
NEW.updated_at = NOW();
RETURNNEW;
END;
$$ LANGUAGE plpgsql;
CREATETRIGGER update_messages_updated_at
BEFORE UPDATEON messages
FOREACHROWEXECUTEFUNCTION update_updated_at_column();
-- Enable Row Level Security (RLS) - important for productionALTER 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
FORALLUSING (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
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.
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
Navigate to Numbers > Buy numbers in the dashboard
Select country (e.g., United States)
Ensure SMS capability is enabled (checkbox)
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
Click Get Number to purchase (confirm pricing)
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.
In Sinch Dashboard, go to SMS > APIs
Scroll to Callback URLs section
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
(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.
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.
@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.)
4. Implementation: Sending SMS and Handling Delivery Webhooks
4.1 Initialize Sinch Client (lib/sinch.ts)
TypeScript
// lib/sinch.tsimport { SinchClient } from'@sinch/sdk-core';
if (!process.env.SINCH_SERVICE_PLAN_ID || !process.env.SINCH_API_TOKEN) {
thrownewError('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
*/exportconst sinchClient = newSinchClient({
servicePlanId: process.env.SINCH_SERVICE_PLAN_ID,
apiToken: process.env.SINCH_API_TOKEN,
region: region,
});
/**
* Helper function to send SMS via Sinch API
* @paramto - Recipient phone number in E.164 format
* @parambody - Message text (max 1600 chars for single concatenated SMS)
* @paramclientReference - Optional client identifier for tracking
* @returns Sinch API response with batch_id
*/exportasyncfunctionsendSMS(to: string, body: string, clientReference?: string) {
if (!process.env.SINCH_FROM_NUMBER) {
thrownewError('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 messagedelivery_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.tsimport { createClient } from'@supabase/supabase-js';
if (!process.env.NEXT_PUBLIC_SUPABASE_URL || !process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
thrownewError('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
*/exportconst 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
*/exportconst 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
*/exportinterfaceSinchDeliveryReportWebhook {
type: 'recipient_delivery_report_sms' | 'recipient_delivery_report_mms';
batch_id: string;
recipient: string; // E.164 phone numbercode: 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 systemoperator_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'
*/exportinterfaceSinchBatchDeliveryReportWebhook {
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
*/exportinterfaceMessageRecord {
id: string; // UUIDbatch_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.tsimport { 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 }
*/exportasyncfunctionPOST(request: NextRequest) {
try {
const { to, body, clientReference } = await request.json();
// Validate inputif (!to || !body) {
returnNextResponse.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)) {
returnNextResponse.json(
{ error: 'Invalid phone number format. Must be E.164 (e.g., +14155551234)' },
{ status: 400 }
);
}
// Send SMS via Sinchconst sinchResponse = awaitsendSMS(to, body, clientReference);
// Store in Supabase with initial statusconst { data, error } = await supabase
.from('messages')
.insert({
batch_id: sinchResponse.id,
recipient: to,
sender: process.env.SINCH_FROM_NUMBER!,
body: body,
status: 'Queued', // Initial statusstatus_code: 400, // Sinch code for Queuedclient_reference: clientReference || null,
})
.select()
.single();
if (error) {
console.error('Supabase insert error:', error);
// SMS sent but DB write failed - log for manual reconciliationreturnNextResponse.json(
{
success: true,
batch_id: sinchResponse.id,
warning: 'SMS sent but failed to log in database',
},
{ status: 201 }
);
}
returnNextResponse.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);
returnNextResponse.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.tsimport { 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
*/exportasyncfunctionPOST(request: NextRequest) {
try {
// Parse webhook payloadconstpayload: 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 codeconst 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)returnNextResponse.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)returnnewNextResponse(null, { status: 204 });
}
console.log('✅ Message status updated:', data[0]);
// Respond with 204 No Content to acknowledge receipt// This stops Sinch from retrying the webhookreturnnewNextResponse(null, { status: 204 });
} catch (error: any) {
console.error('❌ Webhook processing error:', error);
// Return 500 to trigger retry (transient errors)returnNextResponse.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
*/functiongetErrorMessage(code: number, status: string): string | null {
if (code === 0 || status === 'Delivered') returnnull;
consterrorMap: 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:
Code
Status
Type
Description
Action Required
0
Delivered
Final
Message delivered to handset
None (success)
400
Queued
Intermediate
Queued in Sinch system
Normal, wait for dispatch
401
Dispatched
Intermediate
Accepted by carrier SMSC
Normal, wait for delivery
402
Aborted
Final
Unroutable (invalid number)
Validate recipient number, remove from list
403
Aborted
Final
Internal error
Contact Sinch support if recurring
404
Aborted
Final
Temporary failure
Retry with exponential backoff
406
Expired
Final
Message expired
Increase expiry time or retry
407
Cancelled
Final
Cancelled by sender
Expected if cancel API called
413
Aborted
Final
Account blocked
Check 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
Start Next.js Development Server:
Bash
npm run dev
# Server runs at http://localhost:3000
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)
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
Restrict webhook endpoint to Sinch IP ranges (contact Sinch support for current IPs). Configure in next.config.js with middleware or use Vercel Firewall.
Verify Recipient Number Format: Must be E.164 (+14155551234). No spaces, dashes, or parentheses.
Check Sinch Dashboard Logs: SMS > Logs shows all sent messages, delivery status, and error codes.
Verify Sender Number: Ensure SINCH_FROM_NUMBER matches a purchased number in your account.
Check Account Balance: Insufficient credits will cause 413 errors. Add credits in Billing section.
Regional Restrictions: Some countries block SMS from certain sender ID types (e.g., alphanumeric sender IDs blocked in US/Canada). Use purchased number instead.
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:
Verify Webhook URL Configuration: Check Sinch Dashboard > SMS > APIs > Callback URLs. Must be publicly accessible HTTPS URL.
Use the Vonage Messages API and Node.js SDK. The sendSms function in the provided code demonstrates how to initialize the Vonage client and send an SMS message using your API credentials and a Vonage virtual number. It handles asynchronous delivery receipts via webhook, and it includes error handling using try-catch blocks. It includes a clientRef that you can use to correlate messages with your internal system identifiers.
The Vonage Messages API is a multi-channel communication platform that allows you to send SMS, MMS, WhatsApp messages, and more. In this Node.js application, it's used to send SMS messages and configure status webhooks to receive delivery updates.
Vonage uses the Status URL to send real-time delivery receipts (DLRs) to your application as webhooks. This lets your application know the status of sent messages (e.g., delivered, failed) asynchronously, without having to poll the API. You must use a publicly accessible URL like one provided by ngrok during development or your production server's URL for receiving these updates.
Update the Vonage Application's Status URL in the Vonage Dashboard before sending an SMS message, or after restarting your application. This ensures that Vonage knows where to send delivery status updates to your webhook endpoint.
Yes, use ngrok to create a secure tunnel to your locally running Express server. This provides a public HTTPS URL that Vonage can use to reach your webhook endpoint during development. Update your .env file's BASE_URL and your application's Status URL on Vonage Dashboard with this ngrok URL. Be sure to stop and restart your application after setting this URL.
Set up a webhook endpoint in your Node.js Express application (e.g., /webhooks/status) and configure this URL as the Status URL in your Vonage application settings. The webhook endpoint receives real-time delivery status updates from the Vonage API, typically a few seconds to a minute after the message has been sent, depending on network availability.
The Vonage Application ID is a unique identifier for your application within the Vonage platform. It's used along with your private key to authenticate requests to the Messages API and link your virtual numbers and webhooks to your application.
The private key is crucial for secure authentication with the Vonage Messages API. It's used to generate JWTs (JSON Web Tokens) for requests, ensuring only authorized applications can send messages and access related data. Never expose your private key publicly and never commit it to version control.
Use ngrok during local development to expose your local webhook server to the public internet so Vonage can send delivery status updates to your webhook endpoint. Ngrok isn't needed in production once deployed to a server with a public URL.
Implement webhook signature verification using verifySignature method from @vonage/server-sdk. This ensures requests are from Vonage, preventing spoofing attacks. Configure signature secret in Vonage account settings. This step is critical for production.
The 'delivered' status in a Vonage delivery receipt (DLR) webhook indicates that the SMS message has successfully reached the recipient's mobile handset. Not all carriers or countries reliably report this final status. Sometimes the status might remain 'submitted' or 'accepted', even if delivered. Thorough testing is necessary. If you are getting 'submitted', try texting to a different carrier.
Verify ngrok is running and that the URL matches the Status URL configured in your Vonage account. If the Node.js server is running and no webhooks are received, also confirm no firewall is blocking incoming traffic. Inspect the ngrok inspection interface to see if the requests are being received. If the requests are not being received, check the Vonage Status page for system issues. Also check that the recipient number and virtual number formats are valid. Ensure you are responding to the Vonage webhook with a 2xx status code. Ensure your application has Messages set as the default SMS API.
The clientRef is an optional user-defined reference string when sending SMS messages using the Vonage API. It lets you associate a unique identifier from your system with each message, allowing for easier tracking, logging, and reconciliation within your own application logic. It appears in status webhooks and logs.
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:
Technologies Used:
app/api/*/route.tsconvention) for serverless webhook endpoints@sinch/sdk-core): Official SDK version 1.x supporting Promises and TypeScript definitions (GitHub)@supabase/supabase-jsclient librarySystem Architecture:
batch_idimmediately (HTTP 201 Created), then relays the message to the carrier network (SMSC).messagestable) with initial statusQueued(code 400).Deliveredcode 0,Failedcode varies).batch_id,status,code,recipient, and timestamp.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:
+14155551234)Estimated Costs (Development):
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
sinch-sms-tracker), database password, and select region closest to your usershttps://xxxxx.supabase.coandeyJhbGc...)1.2 Create Messages Table for SMS Status Tracking
Execute this SQL in the Supabase SQL Editor (Tools > SQL Editor > New query):
-- 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 automaticallySchema Explanation:
batch_id: Unique identifier returned by Sinch API, used to correlate webhook callbacks with sent messagesstatus: 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)2. Sinch Account and API Setup
Obtain credentials and configure webhook URL in the Sinch dashboard.
2.1 Sign Up and Get Credentials
abc123def456..., ~32 hex characters)Authorization: Bearer YOUR_API_TOKEN). The Service Plan ID is included in API endpoint paths.https://us.sms.api.sinch.comhttps://eu.sms.api.sinch.comhttps://ca.sms.api.sinch.comhttps://au.sms.api.sinch.comhttps://br.sms.api.sinch.com2.2 Purchase Virtual Number
+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.
https://abc123.ngrok.io/api/webhooks/sinch/deliveryhttps://your-app.vercel.app/api/webhooks/sinch/deliveryhttps://user:pass@your-domain.com/api/webhooks/sinch/delivery). Secure but credentials visible in logs.X-Sinch-Signature: secret_value) for additional validation.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
# 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 withlayout.tsx,page.tsx, andglobals.css.3.2 Install Dependencies
# 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/nodeVersion 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 pooling3.3 Configure Environment Variables
Create
.env.localin project root (automatically excluded from Git in Next.js):# .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_hereE.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:
.env.localto version control3.4 Project Structure
4. Implementation: Sending SMS and Handling Delivery Webhooks
4.1 Initialize Sinch Client (
lib/sinch.ts)// 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:
SinchClientconstructor acceptsservicePlanId,apiToken, andregionfor authentication and routingdelivery_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)4.2 Initialize Supabase Client (
lib/supabase.ts)// 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 usesupabaseAdmin(service role key): Bypasses RLS, full database access, use only server-side for administrative operations like webhook processing4.3 TypeScript Interfaces (
types/sinch.ts)// 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)// 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:
Queued(code 400) reflects message acceptance by Sinch, not carrier delivery4.5 API Route: Webhook Handler for SMS Delivery Status (
app/api/webhooks/sinch/delivery/route.ts)// 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:
DeliveredQueuedDispatchedAbortedAbortedAbortedExpiredCancelledAbortedIdempotency Considerations: Sinch may retry webhooks if they don't receive a 2xx response within timeout (~30 seconds). Use
batch_idas idempotency key to prevent duplicate processing. The SupabaseUPDATEoperation 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 ifstatusalready equals incoming status before processing).5. Testing SMS Delivery Status Webhooks
5.1 Local Development with ngrok
Start Next.js Development Server:
npm run dev # Server runs at http://localhost:3000Expose Localhost with ngrok:
Open a new terminal and run:
Copy the HTTPS forwarding URL (e.g.,
https://abc123.ngrok.io)Configure Sinch Webhook URL:
https://abc123.ngrok.io/api/webhooks/sinch/deliveryTest SMS Sending:
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" }'Verify Results:
Terminal: Check
npm run devlogs for "📥 Delivery report webhook received"Phone: Receive SMS within 5-30 seconds
Supabase: Query
messagestable in SQL Editor: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:
{ "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
Initialize Git Repository:
git init git add . git commit -m "Initial commit: Sinch SMS with Next.js and Supabase"Push to GitHub/GitLab:
Create a repository on GitHub and push:
Deploy to Vercel:
.env.local):SINCH_SERVICE_PLAN_IDSINCH_API_TOKENSINCH_REGIONSINCH_FROM_NUMBERNEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEYSUPABASE_SERVICE_ROLE_KEY(optional)WEBHOOK_SECRET(optional)Update Sinch Webhook URL:
https://your-app.vercel.app)https://your-app.vercel.app/api/webhooks/sinch/deliveryTest Production Deployment:
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
.env.localto Git (Next.js automatically excludes it via.gitignore)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:
// 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: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.jswith middleware or use Vercel Firewall.6.3 Rate Limiting
Protect your webhook endpoint from abuse:
// 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:
Error Tracking:
Integrate Sentry for exception tracking:
Monitoring:
6.5 Retry Logic for Failed SMS
Implement retry with exponential backoff for transient Sinch API errors (network timeouts, 5xx responses):
// 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:
+14155551234). No spaces, dashes, or parentheses.SINCH_FROM_NUMBERmatches a purchased number in your account.7.2 Webhook Not Received
Symptom: SMS delivered to phone but database status remains "Queued".
Debugging Steps:
Verify Webhook URL Configuration: Check Sinch Dashboard > SMS > APIs > Callback URLs. Must be publicly accessible HTTPS URL.
Test Webhook Endpoint Manually:
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.
Check ngrok Connection: If using ngrok, ensure tunnel is active (
ngrok http 3000running).Review Webhook Logs: Check Next.js console (
npm run dev) for incoming requests and errors.Inspect Sinch Retry Attempts: Sinch retries webhooks on 5xx/timeout. Check if retries are accumulating (indicates endpoint issues).
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:
RLS Policy Mismatch: If using
supabaseclient (anon key) in webhook handler, RLS may block updates. UsesupabaseAdmininstead.Missing batch_id: If message was sent outside this application,
batch_idwon't exist in database. Add defensive check:if (!data || data.length === 0) { console.warn('No matching message found, ignoring webhook'); return new NextResponse(null, { status: 204 }); // Acknowledge to prevent retries }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:
// 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:
// 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:
-- 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-functions8.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:
// 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/inbound8.4 Message Templates and Parameterization
Reduce code duplication with parameterized templates:
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:
Key Learnings:
batch_idare essential for fast webhook processing at scaleProduction Checklist:
Next Steps:
type: 'mt_media')Related Resources: