# How to Build a SMS Notification System with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

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.

## Before you start

- 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

## Step-by-step guide

### 1. 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.

```
Create an SMS notification system schema in Supabase.

Tables:

1. sms_templates:
   id uuid primary key default gen_random_uuid()
   name text not null unique
   body_template text not null — supports {{variable}} placeholders, max 160 chars per segment
   variables text[] — list of expected variable names for this template
   category text — e.g. 'transactional', 'marketing', 'alert'
   is_active boolean default true
   created_at timestamptz default now()
   updated_at timestamptz default now()

2. notification_queue:
   id uuid primary key default gen_random_uuid()
   to_phone text not null — E.164 format: +1XXXXXXXXXX
   template_id uuid references sms_templates(id)
   variables jsonb default '{}' — the values to substitute into the template
   body text — overrides template if provided (for one-off messages)
   status text default 'pending' — 'pending', 'sending', 'sent', 'delivered', 'failed', 'undelivered', 'cancelled'
   priority integer default 5 — 1 = highest, 10 = lowest
   send_at timestamptz default now() — allows future scheduling
   sent_at timestamptz
   delivered_at timestamptz
   twilio_message_sid text
   twilio_error_code text
   twilio_error_message text
   created_by uuid references auth.users(id)
   created_at timestamptz default now()
   updated_at timestamptz default now()

3. (Optional) phone_opt_outs:
   id uuid primary key default gen_random_uuid()
   phone text not null unique
   opted_out_at timestamptz default now()
   reason text — 'user_request', 'stop_reply', 'undeliverable'

RLS:
- sms_templates: authenticated users SELECT; authenticated INSERT/UPDATE for template managers
- notification_queue: authenticated users can INSERT their own messages (created_by = auth.uid()); SELECT their own messages; service role for status updates
- phone_opt_outs: service role only for INSERT; authenticated SELECT

Indexes:
- notification_queue(status, send_at) — the queue processing query
- notification_queue(twilio_message_sid) — for webhook lookups
- notification_queue(to_phone, created_at) — for per-number history
- 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.

### 2. 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.

```
Create a Supabase Edge Function at supabase/functions/send-sms/index.ts.

The function accepts POST with body: { queue_id: string }

Logic:
1. Fetch the notification_queue row by queue_id with template JOIN
2. If status !== 'pending': return { skipped: true } (idempotent)
3. Check phone_opt_outs for to_phone. If found, update status='cancelled' and return.
4. Render the message body:
   - If row.body is set, use it directly
   - Else fetch the template's body_template and replace {{variable}} with row.variables[variable]
   - Use: body = template.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? '')
5. Update notification_queue.status = 'sending'
6. Call Twilio Messages API:
   const accountSid = Deno.env.get('TWILIO_ACCOUNT_SID')
   const authToken = Deno.env.get('TWILIO_AUTH_TOKEN')
   const from = Deno.env.get('TWILIO_FROM_NUMBER')
   const auth = btoa(accountSid + ':' + authToken)

   const response = await fetch(
     `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`,
     {
       method: 'POST',
       headers: {
         Authorization: 'Basic ' + auth,
         'Content-Type': 'application/x-www-form-urlencoded',
       },
       body: new URLSearchParams({ To: to_phone, From: from, Body: renderedBody }).toString(),
     }
   )
7. Parse response. On success (status 201): update notification_queue with status='sent', twilio_message_sid=data.sid, sent_at=now()
8. On error: update status='failed', twilio_error_code=data.code, twilio_error_message=data.message
9. Return { success: boolean, sid?: string, error?: string }

Return 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.

### 3. 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.

```
// SQL to run in Supabase Dashboard → SQL Editor

CREATE OR REPLACE FUNCTION process_sms_queue()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
  msg RECORD;
  edge_url text := current_setting('app.supabase_url') || '/functions/v1/send-sms';
  service_key text := current_setting('app.service_role_key');
BEGIN
  FOR msg IN
    SELECT id
    FROM notification_queue
    WHERE status = 'pending'
      AND send_at <= now()
      AND NOT EXISTS (
        SELECT 1 FROM phone_opt_outs WHERE phone = notification_queue.to_phone
      )
    ORDER BY priority ASC, send_at ASC
    LIMIT 50
    FOR UPDATE SKIP LOCKED
  LOOP
    PERFORM net.http_post(
      url := edge_url,
      headers := jsonb_build_object(
        'Content-Type', 'application/json',
        'Authorization', 'Bearer ' || service_key
      ),
      body := jsonb_build_object('queue_id', msg.id)
    );
  END LOOP;
END;
$$;

-- Register cron job: runs every minute
SELECT cron.schedule(
  'process-sms-queue',
  '* * * * *',
  'SELECT process_sms_queue()'
);
```

> 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.

### 4. 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.

```
Create a Supabase Edge Function at supabase/functions/twilio-status-webhook/index.ts.

Twilio calls this URL via POST with URL-encoded form data when message status changes.

Logic:
1. Parse the form body: const formData = await req.formData()
   - MessageSid: formData.get('MessageSid')
   - MessageStatus: formData.get('MessageStatus') — 'sent', 'delivered', 'failed', 'undelivered'
   - ErrorCode: formData.get('ErrorCode') (present on failures)
   - ErrorMessage: formData.get('ErrorMessage')

2. Validate the request is from Twilio using Twilio's signature validation:
   - X-Twilio-Signature header must match HMAC-SHA1 of the full URL + sorted params with TWILIO_AUTH_TOKEN
   - If invalid, return 403

3. Find the notification_queue row by twilio_message_sid = MessageSid

4. Update based on MessageStatus:
   - 'delivered': status='delivered', delivered_at=now()
   - 'failed' or 'undelivered': status=MessageStatus, twilio_error_code=ErrorCode, twilio_error_message=ErrorMessage
   - If 'undelivered' and ErrorCode is 21610 (STOP reply): insert into phone_opt_outs

5. Return TwiML 200 response: new Response('<?xml version="1.0"?><Response/>', { headers: { 'Content-Type': 'text/xml' } })

Note: Always return a 200 TwiML response — Twilio retries webhooks that return non-2xx.

Register 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.

### 5. 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.

```
Build an SMS notification dashboard with two pages.

1. src/pages/SmsHistory.tsx — send history:
   - Summary Cards at top: Total Sent (last 30 days), Delivery Rate, Failed Count
   - DataTable showing notification_queue rows with columns:
     To (masked: first 3 digits + *** + last 2), Template Name, Status Badge, Sent At, Delivered At, Message Preview (first 50 chars)
   - Status Badge colors: pending=gray, sending=blue, sent=indigo, delivered=green, failed=red, cancelled=yellow
   - Filter Select: All Status, Pending, Delivered, Failed
   - Search Input: filter by phone number (last 4 digits)
   - 'Resend' action for failed messages: copies the queue row with status='pending' and send_at=now()

2. src/pages/SmsTemplates.tsx — template manager:
   - Left panel: list of templates with name and category Badge
   - Right panel: template editor
     - Name Input
     - Category Select: transactional, marketing, alert
     - Body Textarea: monospace font
     - Live character count with color: green (<=160), yellow (161-320), red (>320 = multiple SMS segments)
     - Variables list: auto-detected from {{variable}} in body, shown as Badges
     - Preview section: Input fields for each detected variable, rendered preview below
   - Save and Delete buttons

3. Send SMS Dialog (reusable component used from other parts of the app):
   - Template Select
   - Phone number Input (with E.164 format hint)
   - Auto-generated variable Inputs based on selected template's variables array
   - Schedule toggle: 'Send Now' vs 'Schedule for Later' (shows DateTimePicker if later)
   - Priority Select: Normal (5), High (2), Urgent (1)
   - On submit: INSERT into notification_queue
```

> Pro 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 example

File: `supabase/functions/send-sms/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, content-type',
  'Content-Type': 'application/json',
}

function renderTemplate(template: string, variables: Record<string, string>): string {
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? '')
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })

  const { queue_id } = await req.json()
  if (!queue_id) {
    return new Response(JSON.stringify({ error: 'Missing queue_id' }), { status: 400, headers: corsHeaders })
  }

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  const { data: msg } = await supabase
    .from('notification_queue')
    .select('*, template:sms_templates(body_template)')
    .eq('id', queue_id)
    .single()

  if (!msg) {
    return new Response(JSON.stringify({ error: 'Queue item not found' }), { status: 404, headers: corsHeaders })
  }

  if (msg.status !== 'pending') {
    return new Response(JSON.stringify({ skipped: true, reason: 'Not pending' }), { headers: corsHeaders })
  }

  const { count: optedOut } = await supabase
    .from('phone_opt_outs')
    .select('*', { count: 'exact', head: true })
    .eq('phone', msg.to_phone)

  if ((optedOut ?? 0) > 0) {
    await supabase.from('notification_queue').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', queue_id)
    return new Response(JSON.stringify({ skipped: true, reason: 'Opted out' }), { headers: corsHeaders })
  }

  const body = msg.body ?? renderTemplate(msg.template?.body_template ?? '', msg.variables ?? {})

  await supabase.from('notification_queue').update({ status: 'sending', updated_at: new Date().toISOString() }).eq('id', queue_id)

  const accountSid = Deno.env.get('TWILIO_ACCOUNT_SID') ?? ''
  const authToken = Deno.env.get('TWILIO_AUTH_TOKEN') ?? ''
  const from = Deno.env.get('TWILIO_FROM_NUMBER') ?? ''

  const twilioRes = await fetch(
    `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`,
    {
      method: 'POST',
      headers: {
        Authorization: 'Basic ' + btoa(`${accountSid}:${authToken}`),
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({ To: msg.to_phone, From: from, Body: body }).toString(),
    }
  )

  const twilioData = await twilioRes.json()

  if (twilioRes.ok && twilioData.sid) {
    await supabase.from('notification_queue').update({
      status: 'sent',
      twilio_message_sid: twilioData.sid,
      sent_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
    }).eq('id', queue_id)
    return new Response(JSON.stringify({ success: true, sid: twilioData.sid }), { headers: corsHeaders })
  } else {
    await supabase.from('notification_queue').update({
      status: 'failed',
      twilio_error_code: String(twilioData.code ?? ''),
      twilio_error_message: twilioData.message ?? 'Unknown error',
      updated_at: new Date().toISOString(),
    }).eq('id', queue_id)
    return new Response(JSON.stringify({ success: false, error: twilioData.message }), { headers: corsHeaders })
  }
})
```

## Common mistakes

- **Calling the Twilio API directly from the frontend (browser)** — The Twilio Account SID and Auth Token would be visible in your JavaScript bundle. Anyone with your credentials can send SMS from your account and run up charges. Fix: 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.
- **Forgetting to handle Twilio webhook responses with TwiML** — Twilio expects status webhooks to return a 200 response. If your webhook returns a non-2xx status, Twilio marks the webhook as failed and retries up to 11 times, flooding your Edge Function with redundant calls. Fix: 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.
- **Not using FOR UPDATE SKIP LOCKED in the queue processing query** — If two pg_cron runs overlap (e.g. one run takes longer than a minute), both processes pick up the same pending messages and send duplicate SMS. Fix: 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.
- **Storing phone numbers without E.164 formatting validation** — Twilio requires E.164 format (+1XXXXXXXXXX). Numbers stored as '555-123-4567' or '(555) 123-4567' cause every send to fail with a Twilio error. Fix: 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.
- **Not checking the phone_opt_outs table before sending** — Users who replied STOP to a previous message expect no further messages. Sending to an opted-out number violates Twilio's terms of service and regulations like TCPA. Fix: 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

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/sms-notification-system
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/sms-notification-system
