apps/docs/content/guides/auth/auth-hooks/send-sms-hook.mdx
The Send SMS Hook replaces Supabase's built-in SMS sending. You can use this hook to:
AppHashInputs
| Field | Type | Description |
|---|---|---|
user | User | The user attempting to sign in. |
sms | object | Metadata specific to the SMS sending process. Includes the OTP. |
<Tabs scrollable size="small" type="underlined"
<TabPanel id="send-sms-json" label="JSON">
{
"user": {
"id": "6481a5c1-3d37-4a56-9f6a-bee08c554965",
"aud": "authenticated",
"role": "authenticated",
"email": "",
"phone": "+1333363128",
"phone_confirmed_at": "2024-05-13T11:52:48.157306Z",
"confirmation_sent_at": "2024-05-14T12:31:52.824573Z",
"confirmed_at": "2024-05-13T11:52:48.157306Z",
"phone_change_sent_at": "2024-05-13T11:47:02.183064Z",
"last_sign_in_at": "2024-05-13T11:52:48.162518Z",
"app_metadata": {
"provider": "phone",
"providers": ["phone"]
},
"user_metadata": {},
"identities": [
{
"identity_id": "3be5e552-65aa-41d9-9db9-2a502f845459",
"id": "6481a5c1-3d37-4a56-9f6a-bee08c554965",
"user_id": "6481a5c1-3d37-4a56-9f6a-bee08c554965",
"identity_data": {
"email_verified": false,
"phone": "+1612341244428",
"phone_verified": true,
"sub": "6481a5c1-3d37-4a56-9f6a-bee08c554965"
},
"provider": "phone",
"last_sign_in_at": "2024-05-13T11:52:48.155562Z",
"created_at": "2024-05-13T11:52:48.155599Z",
"updated_at": "2024-05-13T11:52:48.159391Z"
}
],
"created_at": "2024-05-13T11:45:33.7738Z",
"updated_at": "2024-05-14T12:31:52.82475Z",
"is_anonymous": false
},
"sms": {
"otp": "561166"
}
}
{
"type": "object",
"properties": {
"user": {
"type": "object",
"properties": {
"id": {
"type": "string",
"x-faker": "random.uuid"
},
"aud": {
"type": "string",
"enum": ["authenticated"]
},
"role": {
"type": "string",
"enum": ["anon", "authenticated"]
},
"email": {
"type": "string",
"x-faker": "internet.email"
},
"phone": {
"type": "string",
"x-faker": {
"fake": "{{phone.phoneNumber('+1##########')}}"
}
},
"phone_confirmed_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"confirmation_sent_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"confirmed_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"phone_change_sent_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"last_sign_in_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"app_metadata": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": ["phone"]
},
"providers": {
"type": "array",
"items": {
"type": "string",
"enum": ["phone"]
}
}
}
},
"user_metadata": {
"type": "object",
"x-faker": "random.objectElement"
},
"identities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"identity_id": {
"type": "string",
"x-faker": "random.uuid"
},
"id": {
"type": "string",
"x-faker": "random.uuid"
},
"user_id": {
"type": "string",
"x-faker": "random.uuid"
},
"identity_data": {
"type": "object",
"properties": {
"email_verified": {
"type": "boolean",
"x-faker": "random.boolean"
},
"phone": {
"type": "string",
"x-faker": {
"fake": "{{phone.phoneNumber('+1##########')}}"
}
},
"phone_verified": {
"type": "boolean",
"x-faker": "random.boolean"
},
"sub": {
"type": "string",
"x-faker": "random.uuid"
}
}
},
"provider": {
"type": "string",
"enum": ["phone", "email", "google"]
},
"last_sign_in_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"created_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"updated_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
}
},
"required": [
"identity_id",
"id",
"user_id",
"identity_data",
"provider",
"last_sign_in_at",
"created_at",
"updated_at"
]
}
},
"created_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"updated_at": {
"type": "string",
"format": "date-time",
"x-faker": "date.recent"
},
"is_anonymous": {
"type": "boolean",
"x-faker": "random.boolean"
}
},
"required": [
"id",
"aud",
"role",
"email",
"phone",
"phone_confirmed_at",
"confirmation_sent_at",
"confirmed_at",
"phone_change_sent_at",
"last_sign_in_at",
"app_metadata",
"user_metadata",
"identities",
"created_at",
"updated_at",
"is_anonymous"
]
},
"sms": {
"type": "object",
"properties": {
"otp": {
"type": "string",
"pattern": "^[0-9]{6}$",
"x-faker": {
"fake": "{{helpers.replaceSymbols(######)}}"
}
}
},
"required": ["otp"]
}
},
"required": ["user", "sms"]
}
Outputs
<Tabs scrollable size="small" type="underlined" defaultActiveId="sql" queryGroup="language"
<TabPanel id="sql" label="SQL"> <Tabs scrollable size="small" type="underlined" defaultActiveId="sql-send-message-via-job-queue" > <TabPanel id="sql-send-message-via-job-queue" label="Queue SMS Messages">
Your company uses a worker to manage all messaging related jobs. For performance reasons, the messaging system sends messages in intervals via a job queue. Instead of sending a message immediately, messages are queued and sent in periodic intervals via pg_cron.
Create a table to store jobs
create table job_queue (
job_id uuid primary key default gen_random_uuid(),
job_data jsonb not null,
created_at timestamp default now(),
status text default 'pending',
priority int default 0,
retry_count int default 0,
max_retries int default 2,
scheduled_at timestamp default now()
);
Create the hook:
create or replace function send_sms(event jsonb) returns void as $$
declare
job_data jsonb;
scheduled_time timestamp;
priority int;
begin
-- extract phone and otp from the event json
job_data := jsonb_build_object(
'phone', event->'user'->>'phone',
'otp', event->'sms'->>'otp'
);
-- calculate the nearest 5-minute window for scheduled_time
scheduled_time := date_trunc('minute', now()) + interval '5 minute' * floor(extract('epoch' from (now() - date_trunc('minute', now())) / 60) / 5);
-- assign priority dynamically (example logic: higher priority for earlier scheduled time)
priority := extract('epoch' from (scheduled_time - now()))::int;
-- insert the job into the job_queue table
insert into job_queue (job_data, priority, scheduled_at, max_retries)
values (job_data, priority, scheduled_time, 2);
end;
$$ language plpgsql;
grant all
on table public.job_queue
to supabase_auth_admin;
revoke all
on table public.job_queue
from authenticated, anon;
Create a function to periodically run and dequeue all jobs
create or replace function dequeue_and_run_jobs() returns void as $$
declare
job record;
begin
for job in
select * from job_queue
where status = 'pending'
and scheduled_at <= now()
order by priority desc, created_at
for update skip locked
loop
begin
-- add job processing logic here.
-- for demonstration, we'll just update the job status to 'completed'.
update job_queue
set status = 'completed'
where job_id = job.job_id;
exception when others then
-- handle job failure and retry logic
if job.retry_count < job.max_retries then
update job_queue
set retry_count = retry_count + 1,
scheduled_at = now() + interval '1 minute' -- delay retry by 1 minute
where job_id = job.job_id;
else
update job_queue
set status = 'failed'
where job_id = job.job_id;
end if;
end;
end loop;
end;
$$ language plpgsql;
grant execute
on function public.dequeue_and_run_jobs
to supabase_auth_admin;
revoke execute
on function public.dequeue_and_run_jobs
from authenticated, anon;
Configure pg_cron to run the job on an interval. You can use a tool like crontab.guru to check that your job is running on an appropriate schedule. Ensure that pg_cron is enabled under Database > Extensions
select
cron.schedule(
'* * * * *', -- this cron expression means every minute.
'select dequeue_and_run_jobs();'
);
import { Webhook } from 'https://esm.sh/[email protected]'
import { readAll } from 'https://deno.land/std/io/read_all.ts'
import { Twilio } from 'https://cdn.skypack.dev/twilio'
import * as base64 from 'https://denopkg.com/chiefbiiko/base64/mod.ts'
const accountSid: string | undefined = Deno.env.get('TWILIO_ACCOUNT_SID')
const authToken: string | undefined = Deno.env.get('TWILIO_AUTH_TOKEN')
const fromNumber: string = Deno.env.get('TWILIO_PHONE_NUMBER')
const sendTextMessage = async (
messageBody: string,
accountSid: string | undefined,
authToken: string | undefined,
fromNumber: string,
toNumber: string
): Promise<any> => {
if (!accountSid || !authToken) {
console.log('Your Twilio account credentials are missing. Please add them.')
return
}
const url: string = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`
const encodedCredentials: string = base64.fromUint8Array(
new TextEncoder().encode(`${accountSid}:${authToken}`)
)
const body: URLSearchParams = new URLSearchParams({
To: `+${toNumber}`,
From: fromNumber,
// Uncomment when testing with a fixed number
Body: messageBody,
})
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${encodedCredentials}`,
},
body,
})
return response.json()
}
Deno.serve(async (req) => {
const payload = await req.text()
const base64_secret = Deno.env.get('SEND_SMS_HOOK_SECRET').replace('v1,whsec_', '')
const headers = Object.fromEntries(req.headers)
const wh = new Webhook(base64_secret)
try {
const { user, sms } = wh.verify(payload, headers)
const messageBody = `Your OTP is: ${sms.otp}`
const response = await sendTextMessage(
messageBody,
accountSid,
authToken,
fromNumber,
user.phone
)
if (response.status !== 'queued') {
return new Response(
JSON.stringify({
error: {
http_code: response.code,
message: `Failed to send SMS: ${response.message}. More info: ${response.more_info}`,
},
}),
{
status: response.status,
headers: {
'Content-Type': 'application/json',
},
}
)
}
return new Response(
JSON.stringify({}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
}
)
} catch (error) {
return new Response(
JSON.stringify({
error: {
http_code: 500,
message: `Failed to send sms: ${JSON.stringify(error)}`,
}
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
)
}
})
Your company is expanding into Latin America and would like to use WhatsApp for higher deliverability. Write a hook to send WhatsApp messages to requests from the continent and SMS messages to all other numbers.
import { Webhook } from "https://esm.sh/[email protected]";
import { readAll } from "https://deno.land/std/io/read_all.ts";
import * as base64 from "https://denopkg.com/chiefbiiko/base64/mod.ts";
const accountSid: string | undefined = Deno.env.get("TWILIO_ACCOUNT_SID");
const authToken: string | undefined = Deno.env.get("TWILIO_AUTH_TOKEN");
const fromNumber: string = Deno.env.get("TWILIO_WHATSAPP_NUMBER");
const smsFromNumber: string = Deno.env.get("TWILIO_SMS_NUMBER");
const latinAmericanCountryCodes = ['54', '55', '56', '57', '58', '501', '502', '503', '504', '505', '506', '507', '508', '509', '51', '52', '53', '591', '592', '593', '594', '595', '596', '597', '598', '599'];
const sendMessage = async (
messageBody: string,
accountSid: string | undefined,
authToken: string | undefined,
fromNumber: string,
toNumber: string,
useWhatsApp: boolean,
): Promise < any > => {
if (!accountSid || !authToken) {
console.log("Your Twilio account credentials are missing. Please add them.");
return;
}
const url: string = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`;
const encodedCredentials: string = base64.fromUint8Array(
new TextEncoder().encode(`${accountSid}:${authToken}`),
);
const body: URLSearchParams = new URLSearchParams({
To: useWhatsApp ? `whatsapp:${toNumber}` : toNumber,
From: useWhatsApp ? `whatsapp:${fromNumber}` : smsFromNumber,
Body: messageBody,
});
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": `Basic ${encodedCredentials}`,
},
body,
});
return response.json();
};
Deno.serve(async (req) => {
const payload = await req.text();
const base64_secret = Deno.env.get("SEND_SMS_HOOK_SECRET").replace('v1,whsec_', '');
const headers = Object.fromEntries(req.headers);
const wh = new Webhook(base64_secret);
try {
const {
user,
sms
} = wh.verify(payload, headers);
const messageBody = `Your OTP is: ${sms.otp}`;
const userPhoneNumber = user.phone;
const countryCode = userPhoneNumber.substring(1, userPhoneNumber.indexOf(userPhoneNumber.match(/\d/)!));
const useWhatsApp = latinAmericanCountryCodes.includes(countryCode);
const response = await sendMessage(
messageBody,
accountSid,
authToken,
fromNumber,
userPhoneNumber,
useWhatsApp,
);
if (response.status !== "queued") {
return new Response(
JSON.stringify({
error: `Failed to send message, Error Code: ${response.code} ${response.message} ${response.more_info}`,
}), {
status: response.status,
headers: {
"Content-Type": "application/json",
},
},
);
}
return new Response(
JSON.stringify({
message: "Message sent successfully."
}), {
headers: {
"Content-Type": "application/json",
},
},
);
} catch (error) {
return new Response(
JSON.stringify({
error: `Failed to process the request: ${error}`
}), {
status: 500,
headers: {
"Content-Type": "application/json",
},
},
);
}
});