Build an SMS notification system in Lovable using Twilio via a Supabase Edge Function proxy. Messages queue to a notification_queue table. A pg_cron job processes the queue every minute, sends via Twilio, and handles delivery status webhooks. Templates with {{variable}} substitution let you reuse message formats across your app.
What you're building
Direct browser-to-Twilio calls are not possible — your Twilio Account SID and Auth Token would be visible to users. Instead, all SMS sends go through a Supabase Edge Function that reads credentials from Deno.env and proxies the Twilio API call. The Edge Function is the only place secrets touch.
The notification queue decouples message creation from delivery. Your app code inserts rows into notification_queue with recipient phone number, template ID, and variable values. The queue supports scheduled sends (send_at in the future) and priority levels. A pg_cron job runs every minute, selects up to 50 due messages, calls the Twilio proxy Edge Function for each, and updates the row status.
Delivery status comes back via Twilio webhooks. When a message is delivered, bounced, or failed, Twilio sends a POST to your delivery-status Edge Function. This updates the notification_queue row with the final status, error code, and Twilio SID. Your dashboard reads these statuses to show real-time delivery rates.
SMS templates replace {{variable}} placeholders with actual values at send time. Templates also enforce the 160-character SMS limit — the template editor shows a live character count and warns when adding variables would push a message over the limit.
Final result
A production-ready SMS notification system with queuing, scheduling, delivery tracking, and reusable templates.
Tech stack
Prerequisites
- Twilio account with a purchased phone number (trial accounts can only send to verified numbers)
- TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN saved in Cloud tab → Secrets
- TWILIO_FROM_NUMBER saved in Cloud tab → Secrets (your Twilio phone number in E.164 format: +1XXXXXXXXXX)
- pg_cron enabled in Supabase Dashboard → Database → Extensions
- For delivery webhooks: your Edge Function URL must be added to Twilio Console → Phone Numbers → Active Numbers → webhook URL
Build steps
Create the SMS notification schema
Prompt Lovable to create the notification_queue, sms_templates, and a contacts table if you don't already have one. The schema supports queuing, scheduling, and delivery tracking.
1Create an SMS notification system schema in Supabase.23Tables:451. sms_templates:6 id uuid primary key default gen_random_uuid()7 name text not null unique8 body_template text not null — supports {{variable}} placeholders, max 160 chars per segment9 variables text[] — list of expected variable names for this template10 category text — e.g. 'transactional', 'marketing', 'alert'11 is_active boolean default true12 created_at timestamptz default now()13 updated_at timestamptz default now()14152. notification_queue:16 id uuid primary key default gen_random_uuid()17 to_phone text not null — E.164 format: +1XXXXXXXXXX18 template_id uuid references sms_templates(id)19 variables jsonb default '{}' — the values to substitute into the template20 body text — overrides template if provided (for one-off messages)21 status text default 'pending' — 'pending', 'sending', 'sent', 'delivered', 'failed', 'undelivered', 'cancelled'22 priority integer default 5 — 1 = highest, 10 = lowest23 send_at timestamptz default now() — allows future scheduling24 sent_at timestamptz25 delivered_at timestamptz26 twilio_message_sid text27 twilio_error_code text28 twilio_error_message text29 created_by uuid references auth.users(id)30 created_at timestamptz default now()31 updated_at timestamptz default now()32333. (Optional) phone_opt_outs:34 id uuid primary key default gen_random_uuid()35 phone text not null unique36 opted_out_at timestamptz default now()37 reason text — 'user_request', 'stop_reply', 'undeliverable'3839RLS:40- sms_templates: authenticated users SELECT; authenticated INSERT/UPDATE for template managers41- notification_queue: authenticated users can INSERT their own messages (created_by = auth.uid()); SELECT their own messages; service role for status updates42- phone_opt_outs: service role only for INSERT; authenticated SELECT4344Indexes:45- notification_queue(status, send_at) — the queue processing query46- notification_queue(twilio_message_sid) — for webhook lookups47- notification_queue(to_phone, created_at) — for per-number history48- phone_opt_outs(phone)Pro tip: Add a CHECK constraint on to_phone: CHECK (to_phone ~ '^\+[1-9]\d{1,14}$'). This enforces E.164 format at the database level and prevents malformed phone numbers from reaching Twilio.
Expected result: All tables created with RLS and indexes. TypeScript types generated. The app shell renders in the preview.
Build the Twilio SMS send Edge Function
Create the core Edge Function that sends a single SMS via Twilio. It renders the template, checks the opt-out list, calls Twilio, and updates the queue row status.
1Create a Supabase Edge Function at supabase/functions/send-sms/index.ts.23The function accepts POST with body: { queue_id: string }45Logic:61. Fetch the notification_queue row by queue_id with template JOIN72. If status !== 'pending': return { skipped: true } (idempotent)83. Check phone_opt_outs for to_phone. If found, update status='cancelled' and return.94. Render the message body:10 - If row.body is set, use it directly11 - Else fetch the template's body_template and replace {{variable}} with row.variables[variable]12 - Use: body = template.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? '')135. Update notification_queue.status = 'sending'146. Call Twilio Messages API:15 const accountSid = Deno.env.get('TWILIO_ACCOUNT_SID')16 const authToken = Deno.env.get('TWILIO_AUTH_TOKEN')17 const from = Deno.env.get('TWILIO_FROM_NUMBER')18 const auth = btoa(accountSid + ':' + authToken)1920 const response = await fetch(21 `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`,22 {23 method: 'POST',24 headers: {25 Authorization: 'Basic ' + auth,26 'Content-Type': 'application/x-www-form-urlencoded',27 },28 body: new URLSearchParams({ To: to_phone, From: from, Body: renderedBody }).toString(),29 }30 )317. Parse response. On success (status 201): update notification_queue with status='sent', twilio_message_sid=data.sid, sent_at=now()328. On error: update status='failed', twilio_error_code=data.code, twilio_error_message=data.message339. Return { success: boolean, sid?: string, error?: string }3435Return CORS headers on all responses.Pro tip: Use btoa(accountSid + ':' + authToken) for Basic auth — this is the standard Twilio REST API authentication method. Do not use the Twilio Node SDK in Edge Functions as it is not designed for Deno's runtime.
Expected result: The Edge Function deploys. Calling it with a valid queue_id sends an SMS via Twilio. The notification_queue row updates to 'sent' with the Twilio message SID.
Set up queue processing with pg_cron
Configure pg_cron to process the notification queue every minute. The PostgreSQL function finds pending messages due for delivery and triggers the send Edge Function for each.
1// SQL to run in Supabase Dashboard → SQL Editor23CREATE OR REPLACE FUNCTION process_sms_queue()4RETURNS void5LANGUAGE plpgsql6SECURITY DEFINER7AS $$8DECLARE9 msg RECORD;10 edge_url text := current_setting('app.supabase_url') || '/functions/v1/send-sms';11 service_key text := current_setting('app.service_role_key');12BEGIN13 FOR msg IN14 SELECT id15 FROM notification_queue16 WHERE status = 'pending'17 AND send_at <= now()18 AND NOT EXISTS (19 SELECT 1 FROM phone_opt_outs WHERE phone = notification_queue.to_phone20 )21 ORDER BY priority ASC, send_at ASC22 LIMIT 5023 FOR UPDATE SKIP LOCKED24 LOOP25 PERFORM net.http_post(26 url := edge_url,27 headers := jsonb_build_object(28 'Content-Type', 'application/json',29 'Authorization', 'Bearer ' || service_key30 ),31 body := jsonb_build_object('queue_id', msg.id)32 );33 END LOOP;34END;35$$;3637-- Register cron job: runs every minute38SELECT cron.schedule(39 'process-sms-queue',40 '* * * * *',41 'SELECT process_sms_queue()'42);Pro tip: The FOR UPDATE SKIP LOCKED clause prevents two concurrent pg_cron runs from processing the same message. If cron fires while a previous run is still processing, the locked rows are skipped — no duplicate sends.
Expected result: The cron job is registered in Supabase Dashboard → Database → Cron Jobs. Running SELECT process_sms_queue() manually triggers sends for any pending messages due immediately.
Build the Twilio delivery status webhook
Create the webhook Edge Function that Twilio calls when a message is delivered, fails, or bounces. This keeps your notification_queue delivery status in sync with Twilio.
1Create a Supabase Edge Function at supabase/functions/twilio-status-webhook/index.ts.23Twilio calls this URL via POST with URL-encoded form data when message status changes.45Logic:61. Parse the form body: const formData = await req.formData()7 - MessageSid: formData.get('MessageSid')8 - MessageStatus: formData.get('MessageStatus') — 'sent', 'delivered', 'failed', 'undelivered'9 - ErrorCode: formData.get('ErrorCode') (present on failures)10 - ErrorMessage: formData.get('ErrorMessage')11122. Validate the request is from Twilio using Twilio's signature validation:13 - X-Twilio-Signature header must match HMAC-SHA1 of the full URL + sorted params with TWILIO_AUTH_TOKEN14 - If invalid, return 40315163. Find the notification_queue row by twilio_message_sid = MessageSid17184. Update based on MessageStatus:19 - 'delivered': status='delivered', delivered_at=now()20 - 'failed' or 'undelivered': status=MessageStatus, twilio_error_code=ErrorCode, twilio_error_message=ErrorMessage21 - If 'undelivered' and ErrorCode is 21610 (STOP reply): insert into phone_opt_outs22235. Return TwiML 200 response: new Response('<?xml version="1.0"?><Response/>', { headers: { 'Content-Type': 'text/xml' } })2425Note: Always return a 200 TwiML response — Twilio retries webhooks that return non-2xx.2627Register this Edge Function URL in Twilio Console → Phone Numbers → Active Numbers → 'A MESSAGE COMES IN' → Webhook → HTTP POST.Pro tip: Twilio retries failed webhook deliveries up to 11 times over 72 hours. Make your webhook idempotent — if a delivered status arrives twice for the same MessageSid, the second update is a no-op (delivered_at is already set).
Expected result: The webhook Edge Function is deployed. After Twilio delivers a message, it calls the webhook URL and the notification_queue row updates to 'delivered'. STOP replies auto-populate phone_opt_outs.
Build the SMS dashboard and template manager
Create the admin UI with two sections: a send history DataTable with delivery stats, and a template manager where you create and preview SMS templates.
1Build an SMS notification dashboard with two pages.231. src/pages/SmsHistory.tsx — send history:4 - Summary Cards at top: Total Sent (last 30 days), Delivery Rate, Failed Count5 - DataTable showing notification_queue rows with columns:6 To (masked: first 3 digits + *** + last 2), Template Name, Status Badge, Sent At, Delivered At, Message Preview (first 50 chars)7 - Status Badge colors: pending=gray, sending=blue, sent=indigo, delivered=green, failed=red, cancelled=yellow8 - Filter Select: All Status, Pending, Delivered, Failed9 - Search Input: filter by phone number (last 4 digits)10 - 'Resend' action for failed messages: copies the queue row with status='pending' and send_at=now()11122. src/pages/SmsTemplates.tsx — template manager:13 - Left panel: list of templates with name and category Badge14 - Right panel: template editor15 - Name Input16 - Category Select: transactional, marketing, alert17 - Body Textarea: monospace font18 - Live character count with color: green (<=160), yellow (161-320), red (>320 = multiple SMS segments)19 - Variables list: auto-detected from {{variable}} in body, shown as Badges20 - Preview section: Input fields for each detected variable, rendered preview below21 - Save and Delete buttons22233. Send SMS Dialog (reusable component used from other parts of the app):24 - Template Select25 - Phone number Input (with E.164 format hint)26 - Auto-generated variable Inputs based on selected template's variables array27 - Schedule toggle: 'Send Now' vs 'Schedule for Later' (shows DateTimePicker if later)28 - Priority Select: Normal (5), High (2), Urgent (1)29 - On submit: INSERT into notification_queuePro tip: Show a cost estimate next to the character count: Twilio charges per SMS segment (160 chars = 1 segment, 161-306 chars = 2 segments using GSM encoding). Calculate segments as Math.ceil(charCount / (charCount > 160 ? 153 : 160)) and multiply by your Twilio rate.
Expected result: The SMS history page shows past sends with delivery status badges. The template manager allows creating and previewing templates with live variable substitution. The send dialog inserts to the queue.
Complete code
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'34const corsHeaders = {5 'Access-Control-Allow-Origin': '*',6 'Access-Control-Allow-Headers': 'authorization, content-type',7 'Content-Type': 'application/json',8}910function renderTemplate(template: string, variables: Record<string, string>): string {11 return template.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? '')12}1314serve(async (req: Request) => {15 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })1617 const { queue_id } = await req.json()18 if (!queue_id) {19 return new Response(JSON.stringify({ error: 'Missing queue_id' }), { status: 400, headers: corsHeaders })20 }2122 const supabase = createClient(23 Deno.env.get('SUPABASE_URL') ?? '',24 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''25 )2627 const { data: msg } = await supabase28 .from('notification_queue')29 .select('*, template:sms_templates(body_template)')30 .eq('id', queue_id)31 .single()3233 if (!msg) {34 return new Response(JSON.stringify({ error: 'Queue item not found' }), { status: 404, headers: corsHeaders })35 }3637 if (msg.status !== 'pending') {38 return new Response(JSON.stringify({ skipped: true, reason: 'Not pending' }), { headers: corsHeaders })39 }4041 const { count: optedOut } = await supabase42 .from('phone_opt_outs')43 .select('*', { count: 'exact', head: true })44 .eq('phone', msg.to_phone)4546 if ((optedOut ?? 0) > 0) {47 await supabase.from('notification_queue').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', queue_id)48 return new Response(JSON.stringify({ skipped: true, reason: 'Opted out' }), { headers: corsHeaders })49 }5051 const body = msg.body ?? renderTemplate(msg.template?.body_template ?? '', msg.variables ?? {})5253 await supabase.from('notification_queue').update({ status: 'sending', updated_at: new Date().toISOString() }).eq('id', queue_id)5455 const accountSid = Deno.env.get('TWILIO_ACCOUNT_SID') ?? ''56 const authToken = Deno.env.get('TWILIO_AUTH_TOKEN') ?? ''57 const from = Deno.env.get('TWILIO_FROM_NUMBER') ?? ''5859 const twilioRes = await fetch(60 `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`,61 {62 method: 'POST',63 headers: {64 Authorization: 'Basic ' + btoa(`${accountSid}:${authToken}`),65 'Content-Type': 'application/x-www-form-urlencoded',66 },67 body: new URLSearchParams({ To: msg.to_phone, From: from, Body: body }).toString(),68 }69 )7071 const twilioData = await twilioRes.json()7273 if (twilioRes.ok && twilioData.sid) {74 await supabase.from('notification_queue').update({75 status: 'sent',76 twilio_message_sid: twilioData.sid,77 sent_at: new Date().toISOString(),78 updated_at: new Date().toISOString(),79 }).eq('id', queue_id)80 return new Response(JSON.stringify({ success: true, sid: twilioData.sid }), { headers: corsHeaders })81 } else {82 await supabase.from('notification_queue').update({83 status: 'failed',84 twilio_error_code: String(twilioData.code ?? ''),85 twilio_error_message: twilioData.message ?? 'Unknown error',86 updated_at: new Date().toISOString(),87 }).eq('id', queue_id)88 return new Response(JSON.stringify({ success: false, error: twilioData.message }), { headers: corsHeaders })89 }90})Customization ideas
Two-way SMS with reply handling
Configure a Twilio incoming message webhook (separate Edge Function). Parse replies as commands (e.g. STOP, HELP, YES, NO). Route replies to specific handlers based on message context stored in notification_queue metadata.
SMS opt-in flow
Create an opt-in confirmation system: send an initial SMS asking users to reply YES to subscribe. The incoming webhook checks for YES replies and creates a subscription record. This ensures TCPA compliance.
Bulk send with rate limiting
Add a campaigns table for bulk sends to a segment of contacts. The queue processor sends max 10 per second (Twilio's default rate). Process in batches with a 100ms delay between batches to stay within rate limits.
Multi-provider fallback
Add a provider column to notification_queue (default 'twilio'). If Twilio fails with a temporary error, requeue with provider='sinch' or 'bandwidth' as fallback. The send function routes to the appropriate provider API.
SMS appointment reminders
Connect notification_queue to your appointments or bookings table. A database trigger or pg_cron job inserts reminder messages with send_at = appointment_time - 24 hours automatically when an appointment is created.
Phone number validation
Call Twilio's Lookup API before inserting into notification_queue to validate the phone number. The lookup returns carrier info and whether the number is valid. Reject invalid numbers at insert time to avoid wasted send attempts.
Common pitfalls
Pitfall: Calling the Twilio API directly from the frontend (browser)
How to avoid: Always proxy Twilio calls through a Supabase Edge Function. The Edge Function reads TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN from Deno.env.get() — they are never exposed to the browser.
Pitfall: Forgetting to handle Twilio webhook responses with TwiML
How to avoid: Always return new Response('<?xml version="1.0"?><Response/>', { status: 200, headers: { 'Content-Type': 'text/xml' } }) from your status webhook Edge Function, even if processing failed.
Pitfall: Not using FOR UPDATE SKIP LOCKED in the queue processing query
How to avoid: Add FOR UPDATE SKIP LOCKED to the SELECT in process_sms_queue(). This prevents concurrent runs from processing the same rows — locked rows are skipped by subsequent runs.
Pitfall: Storing phone numbers without E.164 formatting validation
How to avoid: Add a CHECK constraint: CHECK (to_phone ~ '^\+[1-9]\d{1,14}$'). Validate in the UI with a phone input component that enforces E.164 format before inserting.
Pitfall: Not checking the phone_opt_outs table before sending
How to avoid: The send-sms Edge Function checks phone_opt_outs before calling Twilio. The pg_cron query also filters out opted-out numbers with a NOT EXISTS subquery.
Best practices
- Never expose Twilio credentials to the browser — all API calls go through a server-side Edge Function
- Check phone_opt_outs before every send — both in the Edge Function and as a filter in the queue processing query
- Use FOR UPDATE SKIP LOCKED when processing the queue to prevent duplicate sends from concurrent runs
- Always respond to Twilio webhooks with a 200 TwiML response to prevent retry floods
- Store the Twilio message SID in notification_queue so you can cross-reference in the Twilio Console when investigating delivery issues
- Validate E.164 phone format at insert time with a database CHECK constraint — never at send time
- Add a cost_estimate_usd column to notification_queue so you can track spending by campaign or sender
- Test your full flow end-to-end on a Twilio trial account with verified test numbers before going live
AI prompts to try
Copy these prompts to build this project faster.
I'm building a Twilio SMS queue processor in PostgreSQL using pg_cron. My process_sms_queue() function loops through pending messages and calls net.http_post to a Supabase Edge Function for each one. The problem is that net.http_post is asynchronous — pg_cron doesn't wait for the HTTP calls to complete before the function returns. How do I ensure all SMS sends complete before the next cron run? Should I use a different approach like a background worker or a dedicated queue table with a status update on completion?
Add a two-way SMS reply handler to my SMS notification system. Create a Supabase Edge Function at supabase/functions/twilio-incoming/index.ts that handles incoming Twilio SMS webhooks. Parse the body for STOP, START, HELP commands. STOP: insert into phone_opt_outs, reply 'You have been unsubscribed. Reply START to resubscribe.'. START: delete from phone_opt_outs, reply 'You are now subscribed.'. HELP: reply with your support number. Register this Edge Function URL in Twilio Console → Phone Numbers → Messaging → Incoming webhook URL.
In my Lovable SMS notification system, I need to validate Twilio webhook signatures in my status webhook Edge Function. Twilio signs webhooks using HMAC-SHA1 of the full URL concatenated with sorted POST parameters, keyed with my Auth Token. In Deno, how do I implement this validation using the Web Crypto API? The standard Twilio Node.js library isn't available in Deno — show me the raw crypto implementation.
Frequently asked questions
Can I send SMS from a free Twilio trial account?
Yes, but with restrictions. Trial accounts can only send to phone numbers you've verified in the Twilio Console. Every message is also prefixed with 'Sent from your Twilio trial account'. To send to unverified numbers and remove the prefix, you need to upgrade to a paid Twilio account (costs vary by country, typically $0.0079/message in the US).
How many SMS can the queue process per minute?
The default Twilio free tier rate limit is 1 message per second per phone number. With a single Twilio number, the queue processor handles up to 60 messages per minute. For higher volume, purchase additional Twilio numbers and round-robin sends across them. The notification_queue supports a from_number column for this pattern.
What is a Twilio message SID?
A message SID is Twilio's unique identifier for each SMS, starting with 'SM' (e.g. SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx). Storing it in notification_queue.twilio_message_sid lets you look up the message in the Twilio Console for debugging. The status webhook uses it to match Twilio callbacks back to your queue rows.
How do I handle STOP replies from users?
Configure an incoming message webhook (supabase/functions/twilio-incoming) in your Twilio phone number settings. When a user replies STOP, Twilio calls the webhook with the message body. Your Edge Function inserts the phone number into phone_opt_outs. The queue processor and send-sms Edge Function both check this table before sending.
Can I schedule SMS for a specific time zone?
The send_at column stores timestamps in UTC. When inserting a scheduled message from the UI, convert the user's local time to UTC using the user's browser timezone: new Date(localDateTime).toISOString(). Display send_at in the dashboard using the user's local timezone with Intl.DateTimeFormat.
What error code does Twilio return when a number has opted out?
Error code 21610 means the recipient has replied STOP and Twilio's carrier-level opt-out is blocking the message. Your status webhook should check for error code 21610 specifically and insert the number into phone_opt_outs to prevent future send attempts against a number Twilio won't deliver to.
How do I count SMS segments for long messages?
Standard SMS are limited to 160 characters using GSM-7 encoding. Messages over 160 characters are split into 153-character segments (7 characters are used for segment headers). Unicode characters (emoji, non-Latin scripts) reduce the limit to 67 characters per segment. Calculate segments as: charCount <= 160 ? 1 : Math.ceil(charCount / 153). Twilio charges per segment.
Can I get help integrating SMS notifications into my existing Lovable app?
The RapidDev team can help you integrate this SMS queue into any Lovable project — including connecting it to appointment bookings, order confirmations, or alert systems. Reach out at rapidevelopers.com.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation