MessageBird SMS Scheduling and Reminders with Next.js and Supabase
Build a production-ready SMS scheduling and reminder system with MessageBird, Next.js, and Supabase. Learn to schedule SMS reminders, manage automated messages, implement Supabase Cron, and handle time zones, error handling, and delivery tracking.
Schedule a message
Build a production-ready SMS scheduling and reminder system with MessageBird, Next.js, and Supabase. This comprehensive guide shows you how to schedule SMS reminders, manage automated messages through API routes, implement Supabase Cron for reliable task scheduling, and handle time zones, error handling, and delivery tracking in a scalable Node.js application.
Estimated completion time: 2–3 hours
Skill level: Intermediate (requires familiarity with Node.js, React, and REST APIs)
Version Compatibility:
- Node.js: 18.x or higher (LTS recommended; download from nodejs.org)
- npm: 9.x or higher (included with Node.js)
- Next.js: 13.x or 14.x (App Router or Pages Router)
- MessageBird SDK:
messagebird@^4.0.1(npm package, API docs) - Supabase JS Client:
@supabase/supabase-js@^2.x(Supabase docs) - node-cron:
^3.0.x
Minimum System Requirements: 4GB RAM, modern multi-core processor, 1GB free disk space
By the end of this MessageBird SMS scheduling tutorial, you'll have built a production-ready Next.js application that:
- Accepts API requests to schedule SMS messages via MessageBird API
- Stores scheduled messages persistently in Supabase PostgreSQL database
- Reliably sends messages at scheduled times using Supabase Cron or node-cron
- Provides RESTful API endpoints to manage (view, cancel) scheduled messages
- Implements comprehensive error handling, retry logic, and timezone management
- Handles delivery receipts and message status tracking
Target Audience: Node.js developers building SMS reminder systems, appointment notification services, or marketing automation tools who need to implement reliable scheduled messaging with MessageBird and Supabase.
Technologies Used:
- Node.js: JavaScript runtime environment.
- Next.js: React framework for building full-stack web applications with API routes.
- MessageBird SMS API: Cloud communications platform for sending SMS messages (pricing varies by destination; test credits available).
- Supabase: Open-source Firebase alternative with PostgreSQL database, authentication, and real-time subscriptions.
- Supabase Cron: Native Postgres-based cron job scheduler (pg_cron extension) for recurring tasks.
node-cron: Alternative task scheduler library for Node.js based on cron syntax (for development or when not using Supabase Cron).dotenv: Module to load environment variables from a.env.localfile.
Cost Considerations:
- MessageBird charges per SMS sent; rates vary by destination country (see pricing)
- MessageBird offers test credits for new accounts
- MessageBird enforces rate limits: 500 req/s for POST (SMS sending), 50 req/s for GET operations
- Supabase offers a free tier; paid plans start at $25/month for production workloads
- Monitor usage to avoid unexpected charges
System Architecture:
graph LR
A[User / Client<br/>(Browser, API)] --> B(Next.js API Routes<br/>/api/schedule);
B --> C{Scheduling Service<br/>(Supabase Cron or node-cron)};
C --> D[MessageBird<br/>SMS API];
D --> E[User Phone<br/>(SMS Delivery)];
subgraph Next.js Application
B --> B1(Input Validation);
B --> B2(Error Handling);
B --> B3(Logging);
end
subgraph Supabase Backend
C --> C1[(PostgreSQL Database<br/>Scheduled Messages)];
end
B --> C1; // Next.js API interacts with Supabase DB
C --> C1; // Cron job queries pending messages
D --> F[Webhook Handler<br/>(Delivery Receipts)];
F --> C1; // Update message statusPrerequisites:
- Node.js and npm: Install Node.js 18+ on your system. Download from nodejs.org. Verify installation:
node -vandnpm -v. - MessageBird API Account: Sign up at MessageBird Dashboard. Get your API Key from Dashboard → Developers → API access keys. See creating access keys guide.
- MessageBird Virtual Mobile Number (VMN): Purchase a number with SMS capabilities for your region. Navigate to Numbers → Buy numbers in the Dashboard. Note: Some countries have registration requirements.
- Supabase Account and Project: Sign up at supabase.com and create a new project. Get your Project URL and anon/public API key from Project Settings → API.
- Basic Terminal/Command Line Knowledge: Know how to navigate directories and run commands.
- (Optional)
curlor Postman: For testing the API endpoints.
1. Project Setup and Installation
Initialize your Next.js SMS scheduling project and install the MessageBird SDK, Supabase client, and scheduling dependencies.
Common Setup Issues & Solutions:
- Port already in use: Change
PORTin.env.localor kill the process using the port - npm install fails: Clear npm cache (
npm cache clean --force) and retry - Supabase connection errors: Verify project URL and API keys are correct
-
Create Next.js Project:
Open your terminal and create a new Next.js project with TypeScript support.
bashnpx create-next-app@latest sms-scheduler cd sms-schedulerWhen prompted, select:
- TypeScript: Yes
- ESLint: Yes
- Tailwind CSS: Yes (optional, for UI)
src/directory: Yes (recommended)- App Router: Yes (recommended for Next.js 13+)
- Import alias: No (or customize as needed)
-
Install Dependencies:
Install MessageBird SDK, Supabase client,
node-cronfor scheduling, anddotenv.bashnpm install messagebird @supabase/supabase-js node-cron npm install --save-dev @types/node-cronPackage Versions & Security:
- Pin major versions in
package.jsonto avoid breaking changes:"messagebird": "^4.0.1" - Run
npm auditregularly to check for vulnerabilities - Use
npm audit fixto automatically patch security issues - Consider using
npm ciin production for reproducible builds - Review MessageBird SDK changelog for updates
- Pin major versions in
-
Configure Environment Variables:
Create a
.env.localfile in the project root (Next.js convention for local environment variables).env# .env.local # MessageBird Credentials MESSAGEBIRD_API_KEY=your_messagebird_api_key_here MESSAGEBIRD_ORIGINATOR=YourSenderID # Supabase Configuration NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key_here SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key_here # Application Configuration CRON_SCHEDULE="*/30 * * * * *" APP_TIMEZONE=UTC NODE_ENV=developmentEnvironment Variable Security & Best Practices:
- Never commit
.env.localto version control (included in.gitignoreby default) - Use
NEXT_PUBLIC_prefix only for client-accessible variables - Service role key grants admin access—use only in server-side code (API routes, not client)
- Rotate API keys every 90 days or when team members leave
- Use environment-specific files:
.env.development,.env.production - In production, use secure secrets management:
- Vercel: Environment Variables in project settings
- AWS: Secrets Manager or Parameter Store
- Azure: Key Vault
- GCP: Secret Manager
- Validate required environment variables at startup (see Section 5)
- Document all environment variables in
.env.example:
env# .env.example (commit this file) MESSAGEBIRD_API_KEY= MESSAGEBIRD_ORIGINATOR= NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY=MESSAGEBIRD_API_KEY: Find in MessageBird Dashboard → Developers → API access keys (guide).MESSAGEBIRD_ORIGINATOR: The sender ID shown to recipients (alphanumeric, max 11 chars, or a phone number). Note: Some countries require registered sender IDs—see country restrictions.NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY: Find in Supabase Project Settings → API.SUPABASE_SERVICE_ROLE_KEY: Admin-level key, found in same location. Keep this secret!CRON_SCHEDULE: How often to check for due messages.*/30 * * * * *= every 30 seconds (development). Use* * * * *(every minute) for production. See crontab.guru.APP_TIMEZONE: UseUTCfor consistency. Supabase stores timestamps in UTC by default.
- Never commit
-
Project Structure:
Next.js uses file-based routing. Create this structure:
sms-scheduler/ ├── src/ │ ├── app/ # Next.js App Router (Next.js 13+) │ │ ├── api/ │ │ │ └── schedule/ │ │ │ ├── route.ts # POST /api/schedule (create) │ │ │ └── [id]/ │ │ │ └── route.ts # GET/DELETE /api/schedule/[id] │ │ ├── layout.tsx │ │ └── page.tsx │ ├── lib/ │ │ ├── supabase.ts # Supabase client initialization │ │ ├── messagebird.ts # MessageBird client initialization │ │ └── scheduler.ts # Scheduling logic (node-cron) │ └── types/ │ └── index.ts # TypeScript types ├── .env.local ├── .gitignore ├── package.json ├── tsconfig.json └── next.config.jsWhy this structure?
- App Router (
src/app/): Next.js 13+ recommended approach with React Server Components - API Routes (
src/app/api/): Server-side endpoints for scheduling operations lib/: Reusable utility modules (Supabase, MessageBird clients, scheduler)types/: Centralized TypeScript type definitions- Separation of concerns: database, SMS provider, and scheduling logic isolated in
lib/
- App Router (
2. MessageBird API Configuration
Configure your MessageBird account, obtain API credentials, and set up SMS sender IDs for scheduled message delivery.
MessageBird Overview:
- MessageBird (now "Bird") is a cloud communications platform
- Provides SMS, Voice, WhatsApp, and other messaging channels
- REST API with simple authentication via API key
- Pricing varies by destination; test credits available for new accounts
- Free tier limitations: Test credits expire; sandbox mode for testing
-
Get MessageBird API Key:
- Log in to your MessageBird Dashboard.
- Navigate to Developers → API access keys.
- Create a new key or use the existing Live API Key (not Test Key for production).
- Copy the key and store it in your
.env.localfile asMESSAGEBIRD_API_KEY. - Important: Test keys only work with test numbers. Use Live keys for real SMS delivery.
-
Buy a Virtual Mobile Number (VMN):
- In the Dashboard, go to Numbers → Buy numbers.
- Search for numbers in your desired country with SMS capabilities.
- Country-specific considerations:
- USA: Requires 10DLC registration for application-to-person (A2P) messaging (10DLC guide)
- India: Requires DLT (Distributed Ledger Technology) registration
- China: Special restrictions and operator approvals required
- UK/EU: Some countries require sender ID registration
- Check country restrictions page for specifics
- Purchase the number and note it down for
MESSAGEBIRD_ORIGINATOR. - SMS capability verification: Ensure the number shows "SMS" in capabilities list.
-
Webhook Setup for Delivery Receipts (Optional but Recommended):
MessageBird can send delivery status updates (DLRs) via webhooks when messages are delivered or fail.
- In Dashboard, go to Developers → Webhooks (or configure per-message via
reportUrlparameter). - Add a webhook URL pointing to your Next.js application, e.g.,
https://yourdomain.com/api/webhooks/messagebird. - MessageBird will POST delivery reports to this URL with status updates.
- Production deployment: Use a public domain or tunneling service like ngrok for local testing.
- Webhook security: Verify webhook signatures or use HTTPS + secret tokens to prevent spoofing.
- Implement webhook handler in
src/app/api/webhooks/messagebird/route.tsto update message status in Supabase.
- In Dashboard, go to Developers → Webhooks (or configure per-message via
MessageBird Rate Limits & Best Practices:
- Rate limits: 500 POST requests/second for SMS sending, 50 GET requests/second
- Implement exponential backoff for rate limit errors (HTTP 429)
- Use
referencefield to correlate messages with your database records - Set
reportUrlfor per-message delivery receipt webhooks - Monitor your MessageBird balance to avoid service interruption
- Use MessageBird's scheduledDatetime parameter for native API scheduling (alternative to cron-based approach)
3. Supabase Database Setup for SMS Scheduling
Create the PostgreSQL database schema in Supabase to store scheduled messages with proper indexing for performance.
Supabase Overview:
- Open-source Firebase alternative built on PostgreSQL
- Provides instant REST API, real-time subscriptions, authentication, and storage
- Free tier: 500MB database, 1GB file storage, 50k monthly active users
- Paid tiers: Start at $25/month for unlimited API requests and more resources
-
Create Supabase Table:
- Log in to the Supabase Dashboard.
- Select your project.
- Go to Table Editor → New table.
- Create a table named
scheduled_messageswith these columns:
Column Name Type Default Nullable Notes iduuidgen_random_uuid()No Primary key recipienttextNo Phone number (E.164 format preferred) messagetextNo SMS content send_attimestamptzNo Scheduled send time (UTC) statustext'PENDING'No PENDING, SENT, FAILED, CANCELED messagebird_idtextYes MessageBird message ID after sending created_attimestamptznow()No Record creation time updated_attimestamptznow()No Last update time error_messagetextYes Error details on failure Database Indexing for Performance:
Add indexes to optimize queries for pending messages:
sql-- Run in Supabase SQL Editor CREATE INDEX idx_scheduled_messages_status_send_at ON scheduled_messages (status, send_at) WHERE status = 'PENDING'; CREATE INDEX idx_scheduled_messages_created_at ON scheduled_messages (created_at DESC);Why these indexes?
idx_scheduled_messages_status_send_at: Critical for the cron job query that finds pending messages due for sending- Partial index (
WHERE status = 'PENDING') reduces index size and improves performance idx_scheduled_messages_created_at: Speeds up listing recent messages
Field Constraints & Validation:
Add constraints at database level for data integrity:
sql-- Message length constraint (SMS is typically 160 chars for GSM, 70 for Unicode) ALTER TABLE scheduled_messages ADD CONSTRAINT message_length_check CHECK (char_length(message) <= 1600); -- Phone number format validation (basic check for E.164-like format) ALTER TABLE scheduled_messages ADD CONSTRAINT recipient_format_check CHECK (recipient ~ '^\+?[1-9]\d{1,14}$'); -- Status enum constraint ALTER TABLE scheduled_messages ADD CONSTRAINT status_check CHECK (status IN ('PENDING', 'SENT', 'FAILED', 'CANCELED')); -- Ensure send_at is in the future for new records ALTER TABLE scheduled_messages ADD CONSTRAINT send_at_future_check CHECK (send_at > now()); -
Enable Row Level Security (RLS):
For production, enable RLS to control access:
sqlALTER TABLE scheduled_messages ENABLE ROW LEVEL SECURITY; -- Policy: Allow service role to do anything (for API routes) CREATE POLICY "Service role can manage all scheduled messages" ON scheduled_messages FOR ALL TO service_role USING (true) WITH CHECK (true); -- Policy: Authenticated users can view their own messages (if you add user_id column) -- CREATE POLICY "Users can view own messages" -- ON scheduled_messages -- FOR SELECT -- TO authenticated -- USING (auth.uid() = user_id); -
Set Up Updated_at Trigger:
Automatically update
updated_aton row changes:sqlCREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER update_scheduled_messages_updated_at BEFORE UPDATE ON scheduled_messages FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -
Migration Workflow & Rollback:
Use Supabase migrations for version control:
bash# Install Supabase CLI npm install -g supabase # Initialize local Supabase supabase init # Create migration file supabase migration new create_scheduled_messages_table # Apply migrations (local) supabase db reset # Apply migrations (remote) supabase db push # Rollback (if needed) supabase migration repair --status revertedProduction migration strategy:
- Test migrations on staging environment first
- Use transactions to ensure atomic migrations
- Keep migrations small and focused
- Document rollback procedures for each migration
- Never delete migration files from version control
4. Initialize Supabase and MessageBird Clients
Create reusable client initialization modules.
-
Create Supabase Client (
src/lib/supabase.ts):typescript// src/lib/supabase.ts import { createClient } from '@supabase/supabase-js'; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; if (!supabaseUrl || !supabaseServiceKey) { throw new Error('Missing Supabase environment variables'); } // Use service role key for server-side operations (bypasses RLS) export const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey, { auth: { autoRefreshToken: false, persistSession: false, }, }); // For client-side operations (respects RLS) const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; export const supabaseClient = createClient(supabaseUrl, supabaseAnonKey); // Database types export interface ScheduledMessage { id: string; recipient: string; message: string; send_at: string; // ISO 8601 timestamp status: 'PENDING' | 'SENT' | 'FAILED' | 'CANCELED'; messagebird_id: string | null; created_at: string; updated_at: string; error_message: string | null; }Supabase Connection Pooling:
- Supabase automatically manages connection pooling via PgBouncer
- Default pool size: 15 connections (Free tier), up to 1000 (Enterprise)
- For serverless environments (Next.js API routes), use Supabase client (not raw Postgres driver)
- Monitor connection usage in Supabase Dashboard → Database → Connection Pooling
-
Create MessageBird Client (
src/lib/messagebird.ts):typescript// src/lib/messagebird.ts import messagebird from 'messagebird'; const apiKey = process.env.MESSAGEBIRD_API_KEY; if (!apiKey) { throw new Error('MESSAGEBIRD_API_KEY is not set'); } export const messagebirdClient = messagebird(apiKey); export const ORIGINATOR = process.env.MESSAGEBIRD_ORIGINATOR || 'MessageBird'; // Helper function to send SMS export async function sendSMS( recipient: string, message: string ): Promise<{ id: string; status: string }> { return new Promise((resolve, reject) => { messagebirdClient.messages.create( { originator: ORIGINATOR, recipients: [recipient], body: message, }, (err, response) => { if (err) { reject(err); } else { resolve({ id: response.id, status: response.recipients.items[0]?.status || 'unknown', }); } } ); }); }
Graceful Shutdown Handling:
In Next.js, API routes are stateless and don't require explicit connection cleanup. However, for long-running processes (like cron jobs), implement graceful shutdown:
// src/lib/shutdown.ts
export function setupGracefulShutdown() {
const signals = ['SIGTERM', 'SIGINT', 'SIGUSR2'] as const;
signals.forEach((signal) => {
process.on(signal, async () => {
console.log(`Received ${signal}, shutting down gracefully...`);
// Stop accepting new cron job executions
// Finish pending database operations
// Close connections if needed
process.exit(0);
});
});
}5. Implement SMS Scheduling Logic with Supabase Cron
Implement production-grade SMS scheduling using Supabase's native pg_cron extension or node-cron for automated message delivery.
Distributed Scheduling Concerns:
- Horizontal scaling: Running multiple Next.js instances can cause duplicate executions
- Solutions:
- Supabase Cron (recommended): Single scheduler within Postgres, no race conditions
- Distributed locks: Use
pg_advisory_lockfor node-cron in multi-instance setups - Dedicated scheduler service: Single cron instance, multiple API instances
Using Supabase Cron (Recommended for Production):
-
Enable pg_cron Extension:
In Supabase Dashboard, go to Database → Extensions, search for
pg_cron, and enable it. -
Create Cron Job to Process Pending Messages:
In Supabase SQL Editor, run:
sql-- Create function to process pending messages CREATE OR REPLACE FUNCTION process_pending_messages() RETURNS void AS $$ DECLARE msg RECORD; response TEXT; BEGIN -- Find messages due for sending FOR msg IN SELECT id, recipient, message, send_at FROM scheduled_messages WHERE status = 'PENDING' AND send_at <= NOW() FOR UPDATE SKIP LOCKED -- Prevent concurrent processing LOOP BEGIN -- Call Next.js API route to send SMS SELECT content INTO response FROM http(( 'POST', 'https://your-domain.com/api/send-sms-internal', -- Replace with your domain ARRAY[http_header('Content-Type', 'application/json')], 'application/json', json_build_object( 'id', msg.id, 'recipient', msg.recipient, 'message', msg.message )::text )); -- Log success RAISE NOTICE 'Processed message %', msg.id; EXCEPTION WHEN OTHERS THEN -- Update message status to FAILED on error UPDATE scheduled_messages SET status = 'FAILED', error_message = SQLERRM, updated_at = NOW() WHERE id = msg.id; RAISE WARNING 'Failed to process message %: %', msg.id, SQLERRM; END; END LOOP; END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- Schedule the function to run every minute SELECT cron.schedule( 'process-pending-sms', -- Job name '* * * * *', -- Every minute $$SELECT process_pending_messages();$$ );Note: Install the
pg_netorhttpextension to make HTTP requests from Postgres. Alternatively, process messages directly in Postgres using a MessageBird SDK wrapper (advanced).
Alternative: Using node-cron in Next.js (Development/Simple Deployments):
If not using Supabase Cron, implement scheduling in Node.js:
-
Create Scheduler Service (
src/lib/scheduler.ts):typescript// src/lib/scheduler.ts import cron from 'node-cron'; import { supabaseAdmin } from './supabase'; import { sendSMS } from './messagebird'; let cronJob: cron.ScheduledTask | null = null; export function startScheduler() { if (cronJob) { console.log('Scheduler already running'); return; } const schedule = process.env.CRON_SCHEDULE || '* * * * *'; // Every minute if (!cron.validate(schedule)) { console.error(`Invalid CRON_SCHEDULE: ${schedule}, defaulting to every minute`); } cronJob = cron.schedule( cron.validate(schedule) ? schedule : '* * * * *', async () => { console.log(`[${new Date().toISOString()}] Checking for due messages...`); await checkAndSendDueMessages(); }, { scheduled: true, timezone: process.env.APP_TIMEZONE || 'UTC', } ); console.log(`Scheduler started with pattern: ${schedule}`); } export function stopScheduler() { if (cronJob) { cronJob.stop(); cronJob = null; console.log('Scheduler stopped'); } } async function checkAndSendDueMessages() { try { const now = new Date(); // Query pending messages due for sending const { data: dueMessages, error } = await supabaseAdmin .from('scheduled_messages') .select('*') .eq('status', 'PENDING') .lte('send_at', now.toISOString()) .order('send_at', { ascending: true }); if (error) throw error; if (!dueMessages || dueMessages.length === 0) { return; // No messages to send } console.log(`Found ${dueMessages.length} due messages. Processing...`); for (const msg of dueMessages) { try { console.log(`Sending message ID: ${msg.id} to ${msg.recipient}`); const result = await sendSMS(msg.recipient, msg.message); // Update status to SENT await supabaseAdmin .from('scheduled_messages') .update({ status: 'SENT', messagebird_id: result.id, updated_at: new Date().toISOString(), }) .eq('id', msg.id); console.log(`Message ${msg.id} sent successfully. MessageBird ID: ${result.id}`); } catch (err: any) { console.error(`Failed to send message ${msg.id}:`, err); // Update status to FAILED await supabaseAdmin .from('scheduled_messages') .update({ status: 'FAILED', error_message: err.message || 'Unknown error', updated_at: new Date().toISOString(), }) .eq('id', msg.id); } } } catch (error) { console.error('Error during message check/send process:', error); } } -
Start Scheduler in API Route (
src/app/api/cron/start/route.ts):typescript// src/app/api/cron/start/route.ts import { NextResponse } from 'next/server'; import { startScheduler } from '@/lib/scheduler'; export async function POST() { try { startScheduler(); return NextResponse.json({ message: 'Scheduler started' }); } catch (error: any) { return NextResponse.json( { error: error.message }, { status: 500 } ); } }Important: Call this endpoint once when you deploy your app, or invoke
startScheduler()in a global initialization script.
Retry Logic for Failed Messages:
Implement exponential backoff retry for transient failures:
// Add to scheduler.ts
const MAX_RETRIES = 3;
const RETRY_DELAYS = [60000, 300000, 900000]; // 1min, 5min, 15min
async function sendWithRetry(msg: ScheduledMessage, attempt = 0): Promise<void> {
try {
const result = await sendSMS(msg.recipient, msg.message);
await supabaseAdmin
.from('scheduled_messages')
.update({
status: 'SENT',
messagebird_id: result.id,
updated_at: new Date().toISOString(),
})
.eq('id', msg.id);
} catch (err: any) {
if (attempt < MAX_RETRIES && isRetriableError(err)) {
const delay = RETRY_DELAYS[attempt];
console.log(`Retrying message ${msg.id} in ${delay}ms (attempt ${attempt + 1})`);
setTimeout(() => sendWithRetry(msg, attempt + 1), delay);
} else {
// Max retries exceeded or non-retriable error
await supabaseAdmin
.from('scheduled_messages')
.update({
status: 'FAILED',
error_message: `${err.message} (after ${attempt + 1} attempts)`,
updated_at: new Date().toISOString(),
})
.eq('id', msg.id);
}
}
}
function isRetriableError(err: any): boolean {
// Retry on rate limits (429) or server errors (5xx)
return err.statusCode === 429 || (err.statusCode >= 500 && err.statusCode < 600);
}Rate Limiting to Prevent API Throttling:
Implement rate limiting to respect MessageBird's 500 req/s POST limit:
// src/lib/rate-limiter.ts
class RateLimiter {
private tokens: number;
private lastRefill: number;
private readonly maxTokens: number;
private readonly refillRate: number; // tokens per second
constructor(maxTokens: number, refillRate: number) {
this.maxTokens = maxTokens;
this.refillRate = refillRate;
this.tokens = maxTokens;
this.lastRefill = Date.now();
}
async waitForToken(): Promise<void> {
this.refill();
if (this.tokens < 1) {
const waitTime = (1 / this.refillRate) * 1000;
await new Promise(resolve => setTimeout(resolve, waitTime));
this.refill();
}
this.tokens -= 1;
}
private refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
this.lastRefill = now;
}
}
// Limit to 400 req/s (80% of MessageBird's 500 req/s limit for safety)
export const messagebirdRateLimiter = new RateLimiter(400, 400);Use in scheduler:
await messagebirdRateLimiter.waitForToken();
const result = await sendSMS(msg.recipient, msg.message);6. Build Next.js API Routes for SMS Management
Create RESTful API endpoints to schedule SMS messages, retrieve message status, and cancel pending reminders.
API Authentication & Authorization:
For production, implement authentication:
// src/lib/auth.ts
import { NextRequest } from 'next/server';
export function validateApiKey(request: NextRequest): boolean {
const apiKey = request.headers.get('x-api-key');
const validKey = process.env.API_SECRET_KEY;
if (!validKey) {
throw new Error('API_SECRET_KEY not configured');
}
return apiKey === validKey;
}
// Middleware for protected routes
export function requireAuth(handler: Function) {
return async (request: NextRequest, context: any) => {
if (!validateApiKey(request)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
return handler(request, context);
};
}Request Validation with Zod:
Use schema validators for robust input validation:
npm install zod// src/lib/validation.ts
import { z } from 'zod';
export const createScheduleSchema = z.object({
recipient: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid E.164 phone number format'),
message: z.string().min(1).max(1600, 'Message too long'),
sendAt: z.string().datetime('Invalid ISO 8601 datetime'),
});
export type CreateScheduleInput = z.infer<typeof createScheduleSchema>;-
Create Schedule Endpoint (
src/app/api/schedule/route.ts):typescript// src/app/api/schedule/route.ts import { NextRequest, NextResponse } from 'next/server'; import { supabaseAdmin } from '@/lib/supabase'; import { createScheduleSchema } from '@/lib/validation'; export async function POST(request: NextRequest) { try { const body = await request.json(); // Validate input const validation = createScheduleSchema.safeParse(body); if (!validation.success) { return NextResponse.json( { error: 'Validation failed', details: validation.error.errors }, { status: 400 } ); } const { recipient, message, sendAt } = validation.data; // Ensure sendAt is in the future const sendAtDate = new Date(sendAt); if (sendAtDate <= new Date()) { return NextResponse.json( { error: 'Scheduled time must be in the future' }, { status: 400 } ); } // Insert into Supabase const { data, error } = await supabaseAdmin .from('scheduled_messages') .insert({ recipient, message, send_at: sendAtDate.toISOString(), status: 'PENDING', }) .select() .single(); if (error) throw error; return NextResponse.json( { message: 'SMS scheduled successfully', scheduleId: data.id, details: data, }, { status: 201 } ); } catch (error: any) { console.error('Error in POST /api/schedule:', error); return NextResponse.json( { error: error.message || 'Internal server error' }, { status: 500 } ); } } -
Get Schedule Status Endpoint (
src/app/api/schedule/[id]/route.ts):typescript// src/app/api/schedule/[id]/route.ts import { NextRequest, NextResponse } from 'next/server'; import { supabaseAdmin } from '@/lib/supabase'; export async function GET( request: NextRequest, { params }: { params: { id: string } } ) { try { const { id } = params; const { data, error } = await supabaseAdmin .from('scheduled_messages') .select('*') .eq('id', id) .single(); if (error || !data) { return NextResponse.json( { error: 'Scheduled message not found' }, { status: 404 } ); } return NextResponse.json(data); } catch (error: any) { console.error(`Error in GET /api/schedule/${params.id}:`, error); return NextResponse.json( { error: error.message || 'Internal server error' }, { status: 500 } ); } } export async function DELETE( request: NextRequest, { params }: { params: { id: string } } ) { try { const { id } = params; // Check if message exists and is pending const { data: existing, error: fetchError } = await supabaseAdmin .from('scheduled_messages') .select('status') .eq('id', id) .single(); if (fetchError || !existing) { return NextResponse.json( { error: 'Scheduled message not found' }, { status: 404 } ); } if (existing.status !== 'PENDING') { return NextResponse.json( { error: `Cannot cancel message with status: ${existing.status}` }, { status: 400 } ); } // Update status to CANCELED const { data, error } = await supabaseAdmin .from('scheduled_messages') .update({ status: 'CANCELED', updated_at: new Date().toISOString() }) .eq('id', id) .select() .single(); if (error) throw error; return NextResponse.json({ message: 'Scheduled message canceled successfully', details: data, }); } catch (error: any) { console.error(`Error in DELETE /api/schedule/${params.id}:`, error); return NextResponse.json( { error: error.message || 'Internal server error' }, { status: 500 } ); } }
API Rate Limiting (Application-Level):
Protect your API from abuse:
npm install express-rate-limit// src/middleware.ts (Next.js middleware)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const rateLimit = new Map<string, { count: number; resetTime: number }>();
export function middleware(request: NextRequest) {
const ip = request.ip || request.headers.get('x-forwarded-for') || 'unknown';
const now = Date.now();
const limit = 100; // requests per window
const windowMs = 15 * 60 * 1000; // 15 minutes
const record = rateLimit.get(ip);
if (!record || now > record.resetTime) {
rateLimit.set(ip, { count: 1, resetTime: now + windowMs });
} else if (record.count >= limit) {
return NextResponse.json(
{ error: 'Too many requests' },
{ status: 429 }
);
} else {
record.count++;
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};CORS Configuration for Frontend Integration:
// src/app/api/schedule/route.ts (add to each API route)
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN || '*',
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key',
},
});
}Security Middleware with Helmet.js:
For production, add security headers:
npm install helmetCreate custom Next.js middleware:
// src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Security headers
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-XSS-Protection', '1; mode=block');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';"
);
return response;
}Production Logging Setup:
Implement structured logging with log levels and rotation:
npm install winston// src/lib/logger.ts
import winston from 'winston';
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
defaultMeta: { service: 'sms-scheduler' },
transports: [
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' }),
],
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.simple(),
}));
}
export default logger;7. Testing Your SMS Scheduler
Comprehensive Testing Strategy:
- Unit Tests: Test individual functions (validation, SMS sending logic)
- Integration Tests: Test API routes end-to-end
- API Testing: Use curl, Postman, or automated tools
Example: Unit Test with Jest:
npm install --save-dev jest @types/jest ts-jest// __tests__/validation.test.ts
import { createScheduleSchema } from '@/lib/validation';
describe('createScheduleSchema', () => {
it('should validate correct input', () => {
const valid = {
recipient: '+14155552671',
message: 'Test message',
sendAt: '2025-12-31T23:59:59Z',
};
expect(() => createScheduleSchema.parse(valid)).not.toThrow();
});
it('should reject invalid phone number', () => {
const invalid = {
recipient: 'invalid',
message: 'Test',
sendAt: '2025-12-31T23:59:59Z',
};
expect(() => createScheduleSchema.parse(invalid)).toThrow();
});
});API Testing with curl:
# Schedule a message
curl -X POST http://localhost:3000/api/schedule \
-H "Content-Type: application/json" \
-H "x-api-key: your_secret_key" \
-d '{
"recipient": "+14155552671",
"message": "Reminder: Your appointment is tomorrow at 3 PM",
"sendAt": "2025-10-15T15:00:00Z"
}'
# Get message status
curl -X GET http://localhost:3000/api/schedule/{id} \
-H "x-api-key: your_secret_key"
# Cancel message
curl -X DELETE http://localhost:3000/api/schedule/{id} \
-H "x-api-key: your_secret_key"8. Deploying Your SMS Scheduling App
Environment Setup:
- Use Vercel, Netlify, or AWS Amplify for Next.js hosting
- Set environment variables in hosting platform dashboard
- Use Supabase production project (not dev project)
- Configure custom domain and SSL certificates
Database Migration Strategy:
# Run migrations on production Supabase
supabase link --project-ref your-project-ref
supabase db pushMonitoring & Observability:
- Enable Supabase Database Logs
- Set up Vercel Analytics or Sentry for error tracking
- Monitor MessageBird usage dashboard for rate limits and costs
- Set up alerts for failed messages (Supabase functions can trigger emails)
Scaling Strategies:
- Use Supabase Cron for scheduling (scales automatically)
- Implement database read replicas for high-traffic reads
- Use Redis or Upstash for caching frequently accessed data
- Consider message queues (BullMQ, AWS SQS) for high-volume SMS sending
9. Troubleshooting Common SMS Scheduling Issues
Common Errors & Solutions:
| Error | Cause | Solution |
|---|---|---|
| "Invalid phone number format" | Recipient not in E.164 format | Use format +[country code][number], e.g., +14155552671 |
| "Unauthorized" (401 from MessageBird) | Invalid API key | Verify MESSAGEBIRD_API_KEY in .env.local |
| "Rate limit exceeded" (429) | Too many requests | Implement rate limiting (see Section 5) |
| "Message not sent" | Insufficient MessageBird balance | Top up balance in MessageBird Dashboard |
| Supabase connection timeout | Network issues or RLS blocking access | Check RLS policies; use service role key for server-side |
| Cron job not running | pg_cron not enabled or schedule invalid | Enable pg_cron extension; verify cron syntax |
| Duplicate message sends | Multiple scheduler instances | Use Supabase Cron or implement distributed locks |
10. Best Practices for Production SMS Scheduling
Timezone Handling:
- Store all timestamps in UTC (Supabase default)
- Convert to user's local timezone only in UI
- Clearly document timezone expectations in API
- Use ISO 8601 format with timezone info:
2025-10-15T15:00:00Z
Scheduling Edge Cases:
- Past timestamps: Reject at API level (validation in Section 6)
- Far-future dates: Consider maximum scheduling window (e.g., 1 year)
- Daylight Saving Time: Use UTC to avoid DST ambiguity
- Leap seconds: PostgreSQL handles automatically
Delivery Receipt Handling:
Create webhook endpoint to update message status:
// src/app/api/webhooks/messagebird/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// MessageBird sends: { id, reference, recipient, status, statusDatetime, ... }
const { id: messagebirdId, status, statusReason } = body;
// Map MessageBird status to our status
const ourStatus = status === 'delivered' ? 'SENT' : 'FAILED';
// Update message in database
await supabaseAdmin
.from('scheduled_messages')
.update({
status: ourStatus,
error_message: statusReason,
updated_at: new Date().toISOString(),
})
.eq('messagebird_id', messagebirdId);
return NextResponse.json({ received: true });
} catch (error: any) {
console.error('Webhook error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}Monitoring & Alerting:
- Set up Supabase Database Webhooks to trigger alerts on failed messages
- Use Vercel Monitoring or Datadog for application performance
- Monitor MessageBird balance to avoid service interruption
- Track key metrics: messages sent/day, failure rate, average latency
Performance Optimization:
- Database indexes: Already covered in Section 3
- Caching: Cache frequently accessed data (message templates, user preferences) in Redis
- Batch processing: Send multiple SMS in parallel (respect rate limits)
- Query optimization: Use
select()to fetch only needed columns
Bulk Scheduling Endpoint:
Add endpoint for scheduling multiple messages at once:
// src/app/api/schedule/bulk/route.ts
export async function POST(request: NextRequest) {
const { messages } = await request.json(); // Array of { recipient, message, sendAt }
// Validate each message
const validatedMessages = messages.map((msg: any) =>
createScheduleSchema.parse(msg)
);
// Insert in batch
const { data, error } = await supabaseAdmin
.from('scheduled_messages')
.insert(validatedMessages.map(msg => ({
recipient: msg.recipient,
message: msg.message,
send_at: msg.sendAt,
status: 'PENDING',
})))
.select();
if (error) throw error;
return NextResponse.json({
message: `${data.length} messages scheduled`,
scheduleIds: data.map(d => d.id)
});
}List Schedules with Pagination:
// src/app/api/schedule/list/route.ts
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '20');
const status = searchParams.get('status') || undefined;
const offset = (page - 1) * limit;
let query = supabaseAdmin
.from('scheduled_messages')
.select('*', { count: 'exact' })
.range(offset, offset + limit - 1)
.order('created_at', { ascending: false });
if (status) {
query = query.eq('status', status);
}
const { data, error, count } = await query;
if (error) throw error;
return NextResponse.json({
data,
pagination: {
page,
limit,
total: count,
totalPages: Math.ceil((count || 0) / limit),
},
});
}Conclusion: Your MessageBird SMS Scheduler is Ready
You've successfully built a production-ready SMS scheduling system using MessageBird API, Next.js, and Supabase Cron. Your system includes:
- Scheduled SMS delivery with precise timing control
- Persistent storage in Supabase PostgreSQL
- RESTful API for scheduling, viewing, and canceling messages
- Error handling and retry logic for reliability
- Rate limiting and security best practices
- Scalable architecture using Supabase Cron or node-cron
Next Steps:
- Add user authentication with Supabase Auth
- Build a frontend UI with React (already set up in Next.js)
- Implement message templates and personalization
- Add recurring schedule support (daily, weekly reminders)
- Integrate with calendar APIs (Google Calendar, Outlook) for appointment reminders
- Set up comprehensive monitoring and alerting
- Implement A/B testing for message content effectiveness
Resources:
- MessageBird API Documentation
- MessageBird Node.js SDK GitHub
- Supabase Documentation
- Supabase Cron Guide
- Next.js Documentation
- pg_cron Extension
- E.164 Phone Number Format
- Cron Syntax Reference
For production deployment, review MessageBird's country-specific regulations and ensure compliance with SMS messaging laws (TCPA in USA, GDPR in EU, etc.).