AI 예약 사이트에 SMS 알림 추가하기
누군가 예약을 신청하면 자동으로 SMS를 보냅니다.
로드맵과 자료
처음이신가요? 여기서 시작하세요
이 가이드는 AI 예약 사이트 프로젝트에 자동 SMS 알림을 더합니다. 아직 예약 사이트를 만들지 않았다면 메인 가이드부터 먼저 진행하세요.
AI로 예약 사이트 만들기
메인 가이드 열기
MAIN GUIDE
https://profitstudio.app/video/build-booking-website-ai
Twilio 정보 가져오기
Twilio 계정 만들기
Twilio 열기
https://www.twilio.com/
이 값들은 Twilio 대시보드에서 가져옵니다.
Twilio 설정 값
TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_PHONE_NUMBER
TWILIO_ACCOUNT_SID
Account SID
TWILIO_AUTH_TOKEN
Auth Token
TWILIO_PHONE_NUMBER
Twilio 전화번호
SMS를 지원하는 Twilio 번호 선택하기
Supabase에 Secrets 추가하기
Supabase Edge Function Secrets 열기
Supabase 열기
https://supabase.com/dashboard/projects
Supabase → Project Settings → Edge Functions → Secrets
필요한 Twilio secret 추가하기
필요한 Secrets
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TWILIO_AUTH_TOKEN=your_auth_token TWILIO_PHONE_NUMBER=+1234567890
고객 SMS는 기본으로 켜져 있습니다. 사장님 SMS는 선택 사항이며 기본적으로 꺼져 있습니다.
선택: 사장님에게 SMS 보내기
사장님 SMS (선택)
SEND_OWNER_SMS=true OWNER_PHONE_NUMBER=+1234567890
새 예약 요청이 들어올 때 사장님도 SMS를 받고 싶은 경우에만 추가하세요.
SMS Edge Function 만들기
정확히 이 이름으로 새 Edge Function을 만듭니다: send-sms-notification
SMS 문구 템플릿 확인하기
생성된 SMS 함수 코드를 복사해서 붙여넣기
SMS 알림 함수
// supabase/functions/send-sms-notification/index.ts // // Sends SMS notifications when a new booking request is created. // Default: sends booking request SMS to the customer. // Optional: also sends SMS notification to the business owner. // // Required Supabase Secrets: // TWILIO_ACCOUNT_SID // TWILIO_AUTH_TOKEN // TWILIO_PHONE_NUMBER // // Optional Owner SMS Secrets: // SEND_OWNER_SMS=true // OWNER_PHONE_NUMBER=+1234567890 const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", }; // ===== CUSTOMIZE THESE VALUES ===== const BUSINESS_NAME = "Your Business Name"; const CUSTOMER_SMS_TEMPLATE = "Hi {{customer_name}}, we received your {{service_name}} booking request for {{appointment_date}} at {{appointment_time}}. We'll contact you soon to confirm."; const OWNER_SMS_TEMPLATE = "New booking request: {{customer_name}} requested {{service_name}} on {{appointment_date}} at {{appointment_time}}. Phone: {{customer_phone}}"; // Default booking field names used in this guide. // If your project uses different field names, update them here. const FIELD_MAP = { customerName: ["full_name", "customer_name", "name"], customerPhone: ["phone", "phone_number", "customer_phone"], appointmentDate: ["appointment_date", "date", "booking_date"], appointmentTime: ["start_time", "appointment_time", "time"], serviceName: ["service_name", "service"], serviceId: ["service_id"], }; // ===== END CUSTOMIZATION ===== function json(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { ...CORS_HEADERS, "Content-Type": "application/json" }, }); } function getEnvFlag(name: string, defaultValue = false): boolean { const value = Deno.env.get(name); if (!value) return defaultValue; return value.toLowerCase() === "true"; } function getField(record: Record<string, unknown>, keys: string[]): string { for (const key of keys) { const value = record[key]; if (typeof value === "string" && value.trim()) { return value.trim(); } } return ""; } function formatTime(time: string): string { if (!time) return ""; return time.length >= 5 ? time.slice(0, 5) : time; } function renderTemplate( template: string, values: Record<string, string>, ): string { return template.replace(/\{\{(.*?)\}\}/g, (_, key) => { return values[key.trim()] ?? ""; }); } async function fetchServiceName( serviceId: string, supabaseUrl: string, serviceRoleKey: string, ): Promise<string> { try { const url = `${supabaseUrl}/rest/v1/services?id=eq.${encodeURIComponent( serviceId, )}&select=name`; const response = await fetch(url, { headers: { apikey: serviceRoleKey, Authorization: `Bearer ${serviceRoleKey}`, }, }); if (!response.ok) return ""; const rows = await response.json(); return rows?.[0]?.name ?? ""; } catch { return ""; } } async function sendTwilioSms(options: { accountSid: string; authToken: string; from: string; to: string; body: string; }) { const { accountSid, authToken, from, to, body } = options; const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`; const form = new URLSearchParams({ From: from, To: to, Body: body, }); const response = await fetch(url, { method: "POST", headers: { Authorization: "Basic " + btoa(`${accountSid}:${authToken}`), "Content-Type": "application/x-www-form-urlencoded", }, body: form.toString(), }); const data = await response.json().catch(() => null); return { ok: response.ok, status: response.status, data, }; } Deno.serve(async (req) => { if (req.method === "OPTIONS") { return new Response("ok", { headers: CORS_HEADERS }); } if (req.method !== "POST") { return json({ success: false, error: "Method not allowed" }, 405); } const accountSid = Deno.env.get("TWILIO_ACCOUNT_SID"); const authToken = Deno.env.get("TWILIO_AUTH_TOKEN"); const twilioPhoneNumber = Deno.env.get("TWILIO_PHONE_NUMBER"); const sendCustomerSms = getEnvFlag("SEND_CUSTOMER_SMS", true); const sendOwnerSms = getEnvFlag("SEND_OWNER_SMS", false); const ownerPhoneNumber = Deno.env.get("OWNER_PHONE_NUMBER"); if (!accountSid || !authToken || !twilioPhoneNumber) { return json( { success: false, error: "Missing Twilio secrets. Please add TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER.", }, 500, ); } let payload; try { payload = await req.json(); } catch { return json({ success: false, error: "Invalid JSON body" }, 400); } const record = payload?.record; if (!record) { return json({ success: false, error: "Missing record in payload" }, 400); } const customerName = getField(record, FIELD_MAP.customerName) || "Customer"; const customerPhone = getField(record, FIELD_MAP.customerPhone); const appointmentDate = getField(record, FIELD_MAP.appointmentDate) || "your selected date"; const appointmentTime = formatTime(getField(record, FIELD_MAP.appointmentTime)) || "your selected time"; let serviceName = getField(record, FIELD_MAP.serviceName) || "your service"; const serviceId = getField(record, FIELD_MAP.serviceId); const supabaseUrl = Deno.env.get("SUPABASE_URL"); const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); if (serviceId && supabaseUrl && serviceRoleKey) { const fetchedServiceName = await fetchServiceName( serviceId, supabaseUrl, serviceRoleKey, ); if (fetchedServiceName) { serviceName = fetchedServiceName; } } const templateValues = { business_name: BUSINESS_NAME, customer_name: customerName, customer_phone: customerPhone || "Not provided", service_name: serviceName, appointment_date: appointmentDate, appointment_time: appointmentTime, }; const results = { customer_sms: "not_sent", owner_sms: "not_sent", errors: [] as unknown[], }; if (sendCustomerSms) { if (!customerPhone) { results.customer_sms = "skipped_missing_customer_phone"; } else { const customerMessage = renderTemplate( CUSTOMER_SMS_TEMPLATE, templateValues, ); const customerResult = await sendTwilioSms({ accountSid, authToken, from: twilioPhoneNumber, to: customerPhone, body: customerMessage, }); if (!customerResult.ok) { results.customer_sms = "failed"; results.errors.push({ type: "customer_sms_failed", status: customerResult.status, twilio: customerResult.data, }); } else { results.customer_sms = "sent"; } } } if (sendOwnerSms) { if (!ownerPhoneNumber) { results.owner_sms = "skipped_missing_owner_phone"; } else { const ownerMessage = renderTemplate( OWNER_SMS_TEMPLATE, templateValues, ); const ownerResult = await sendTwilioSms({ accountSid, authToken, from: twilioPhoneNumber, to: ownerPhoneNumber, body: ownerMessage, }); if (!ownerResult.ok) { results.owner_sms = "failed"; results.errors.push({ type: "owner_sms_failed", status: ownerResult.status, twilio: ownerResult.data, }); } else { results.owner_sms = "sent"; } } } if (results.errors.length > 0) { return json( { success: false, message: "One or more SMS messages failed.", results, }, 502, ); } return json({ success: true, message: "SMS notification process completed.", results, }); });
업체명
내 업체명
고객 SMS 메시지
{{customer_name}} 고객님, {{appointment_date}} {{appointment_time}} {{service_name}} 예약 요청을 접수했습니다. 확정 안내를 곧 보내 드리겠습니다.
사장님 SMS 메시지
새 예약 요청: {{customer_name}} 고객님이 {{appointment_date}} {{appointment_time}}에 {{service_name}}을(를) 요청했습니다. 전화: {{customer_phone}}
선택: 필요하면 필드 이름 수정하기
이 SMS 함수는 다음과 같은 일반적인 예약 필드와 함께 작동합니다. full_name, phone, appointment_date, start_time, service_id, service_name. 프로젝트에서 다른 필드 이름을 쓴다면, 함수 안의 FIELD_MAP을 예약 테이블에 맞게 수정하세요.
FIELD_MAP
const FIELD_MAP = { customerName: ["full_name", "customer_name", "name"], customerPhone: ["phone", "phone_number", "customer_phone"], appointmentDate: ["appointment_date", "date", "booking_date"], appointmentTime: ["start_time", "appointment_time", "time"], serviceName: ["service_name", "service"], serviceId: ["service_id"], };
웹훅 연결하기
예약 테이블 선택하기
이 영상에서는 public.appointments 를 사용합니다. 프로젝트에서 다른 예약 테이블을 쓴다면 그 테이블을 선택하세요.
Supabase Database Webhooks 로 이동하기
Supabase → Database → Webhooks → Create webhook
다음 웹훅 설정을 사용합니다
웹훅 설정
Table: public.appointments Event: Insert Type: Supabase Edge Functions Function: send-sms-notification Method: POST
Insert 이벤트만 켜기
SMS 알림 테스트하기
사이트에서 새 예약 넣어 보기
고객이 SMS를 받았는지 확인하기
Supabase Edge Function 로그 확인하기
Supabase → Edge Functions → send-sms-notification → Logs
필요하면 Twilio Messaging 로그 확인하기
Twilio → Messaging → Logs
공개하기 전에 국가별 SMS 규정을 확인하세요
SMS 요건은 국가마다 다릅니다
SMS 요건은 수신 국가에 따라 다릅니다. 실제 서비스에 적용하기 전에 대상 국가의 Twilio 메시지 요건을 확인하세요. 예를 들어 미국 번호로 앱에서 SMS를 보내려면 A2P 10DLC 등록이 필요할 수 있습니다.