code examples
code examples
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.tsconvention) 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-jsclient 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_idimmediately (HTTP 201 Created), then relays the message to the carrier network (SMSC). - The application stores the outgoing message details in Supabase (
messagestable) with initial statusQueued(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.,
Deliveredcode 0,Failedcode 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.
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
- Navigate to Supabase Dashboard and sign in
- Click New Project
- 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.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)- 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
- Create Account: Visit Sinch Customer Dashboard and sign up (free tier includes $2 credit)
- 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.
- 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
- US:
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
- Development (ngrok):
- (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.
- Basic Auth: Include credentials in URL (
- 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
# 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
# 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 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):
# .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:
- Never commit
.env.localto 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)
// 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)- Error handling captures Sinch API errors (rate limits, invalid numbers, insufficient credit) for upstream handling
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 processing
4.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:
- 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)
// 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:
| 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:
bashnpm run dev # Server runs at http://localhost:3000 -
Expose Localhost with ngrok:
Open a new terminal and run:
bashngrok http 3000Copy 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 - Click Save
-
Test SMS Sending:
bashcurl -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:sqlSELECT * 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:
bashgit 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:
bashgit remote add origin https://github.com/your-username/sinch-sms-nextjs-supabase.git git branch -M main git push -u origin main -
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_IDSINCH_API_TOKENSINCH_REGIONSINCH_FROM_NUMBERNEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEYSUPABASE_SERVICE_ROLE_KEY(optional)WEBHOOK_SECRET(optional)
- Click Deploy
-
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
- Copy your Vercel deployment URL (e.g.,
-
Test Production Deployment:
bashcurl -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.localto 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:
// 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.js with middleware or use Vercel Firewall.
6.3 Rate Limiting
Protect your webhook endpoint from abuse:
npm install @upstash/ratelimit @upstash/redis// 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:
bashnpm 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):
// 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:
- 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_NUMBERmatches 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.
-
Test Webhook Endpoint Manually:
bashcurl -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:typescriptif (!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/inbound
8.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:
- 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_idare 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: