How to Send Automated SMS Appointment Reminders with PulseSMS and Your Booking System
A step-by-step developer guide to connecting PulseSMS with salon and appointment booking platforms — reduce no-shows with automated reminders, confirmations, and follow-ups.
No-shows are one of the biggest revenue leaks for salons, barbershops, spas, and any appointment-based business. Studies consistently show that a simple SMS reminder sent 24 hours — and again 2 hours — before an appointment can cut no-show rates by up to 50%.
This guide walks you through connecting PulseSMS to your booking system to fire those reminders automatically — covering authentication, triggering sends from booking webhooks, handling confirmations, and sending follow-up messages after the appointment.
The full interactive API reference is at pulse-sms.com/reference.
Before you start
You'll need:
- A PulseSMS account (sign up free)
- An API key — find it under Settings → API Keys in your dashboard (it starts with
pk_live_...) - At least one Android device connected as a gateway (your sending SIM — the guide covers this in Step 2)
- A booking system that fires webhooks when appointments are created or updated (Acuity, Calendly, Fresha, Mindbody, Booksy, or a custom platform all work)
The reminder flow we'll build
Booking created
│
▼
[Webhook fires]
│
├─── Immediate confirmation SMS ──► "Hi Sarah, you're booked for..."
│
├─── 24h before appointment ──────► "Reminder: your appointment is tomorrow..."
│
└─── 2h before appointment ──────► "See you in 2 hours! Reply CANCEL to cancel."
Appointment completes
│
└─── Follow-up SMS ──────────────► "Thanks for visiting! Leave us a review..."All three touchpoints run through the same POST /api/sms/send endpoint — the only difference is timing.
Step 1 — Authenticate and get a token
Every API call requires a short-lived JWT Bearer token. Exchange your API key once and cache it for up to 24 hours.
curl -X POST https://pulse-sms.com/api/auth/token \
-H "Content-Type: application/json" \
-d '{"apiKey": "pk_live_4f8a2c1d9e3b7f6a0c5e2d8b4a9f1c3e"}'Response:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": 86400,
"organizationId": "org_01xyz123"
}Tokens expire after 24 hours. Cache and reuse — don't request a new one on every webhook hit.
TypeScript — reusable PulseSMS client
Drop this into lib/pulse.ts and import it from your webhook handlers:
// lib/pulse.ts
const BASE_URL = "https://pulse-sms.com";
interface TokenCache {
token: string;
expiresAt: number;
}
let cache: TokenCache | null = null;
async function getToken(): Promise<string> {
if (cache && Date.now() < cache.expiresAt - 60_000) return cache.token;
const res = await fetch(`${BASE_URL}/api/auth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: process.env.PULSE_API_KEY }),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`PulseSMS auth failed: ${error}`);
}
const { token, expiresIn } = await res.json();
cache = { token, expiresAt: Date.now() + expiresIn * 1000 };
return token;
}
export async function pulseRequest<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = await getToken();
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...options.headers,
},
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`PulseSMS ${options.method ?? "GET"} ${path}: ${error}`);
}
return res.json();
}Python — reusable session
# pulse.py
import os, time, requests
class PulseClient:
def __init__(self, api_key: str):
self._api_key = api_key
self._token: str | None = None
self._expires_at: float = 0
self._session = requests.Session()
def _get_token(self) -> str:
if self._token and time.time() < self._expires_at - 60:
return self._token
resp = self._session.post(
"https://pulse-sms.com/api/auth/token",
json={"apiKey": self._api_key},
)
resp.raise_for_status()
data = resp.json()
self._token = data["token"]
self._expires_at = time.time() + data["expiresIn"]
return self._token
def post(self, path: str, body: dict) -> dict:
resp = self._session.post(
f"https://pulse-sms.com{path}",
json=body,
headers={"Authorization": f"Bearer {self._get_token()}"},
)
resp.raise_for_status()
return resp.json()
pulse = PulseClient(api_key=os.environ["PULSE_API_KEY"])Step 2 — Connect your Android gateway device
PulseSMS routes messages through an Android phone on your SIM, so your SMS comes from a real local number — not a short code or unknown sender.
- Download the PulseSMS Gateway APK from pulse-sms.com/download
- Install and open it on the Android phone you want to use as your gateway
- In your dashboard go to Devices → Connect device
- Scan the QR code with the app
- The device appears in the list with
status: onlinewithin seconds
Verify it's reachable before sending:
curl https://pulse-sms.com/api/devices \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."[
{
"id": "dvc_01a2b3c4d5e6f7g8",
"deviceName": "Salon Front Desk — SIM",
"phoneNumber": "+14155552671",
"status": "online",
"smsSent": 3041,
"lastSeenAt": "2026-04-11T09:45:00Z"
}
]If no device has "status": "online", the send request will return a 400. Keep the phone plugged in at the front desk.
Step 3 — Receive booking webhooks
Most booking platforms let you register a webhook URL that fires whenever an appointment is created, updated, or cancelled. Set your endpoint to something like:
https://your-server.com/webhooks/bookingA typical booking webhook payload looks like this (the exact shape varies by platform, but these fields are almost always present):
{
"event": "appointment.created",
"appointment": {
"id": "appt_9f3c2a1b",
"clientName": "Sarah Johnson",
"clientPhone": "+14155559823",
"service": "Balayage & Blow Dry",
"staffName": "Jamie",
"startsAt": "2026-04-13T14:00:00Z",
"durationMinutes": 120,
"locationName": "Downtown Studio"
}
}Here's a minimal Express handler that receives the webhook, sends a confirmation immediately, and schedules the two reminder jobs:
// routes/webhooks.ts
import express from "express";
import { sendAppointmentSMS } from "../lib/appointmentSMS";
import { scheduleReminders } from "../lib/scheduler";
const router = express.Router();
router.post("/booking", async (req, res) => {
const { event, appointment } = req.body;
// Acknowledge immediately — booking platforms retry on timeout
res.status(200).json({ received: true });
if (event === "appointment.created") {
// 1. Send instant confirmation
await sendAppointmentSMS("confirmation", appointment);
// 2. Schedule 24h and 2h reminders
await scheduleReminders(appointment);
}
if (event === "appointment.cancelled") {
await sendAppointmentSMS("cancellation", appointment);
}
});
export default router;Tip: Always respond
200to the webhook immediately, then do the async work. Most platforms will retry failed webhooks — which can result in duplicate SMS sends.
Step 4 — Send the confirmation SMS
As soon as a booking is created, fire a confirmation so the client has the appointment details saved in their messages.
// lib/appointmentSMS.ts
import { pulseRequest } from "./pulse";
interface Appointment {
id: string;
clientName: string;
clientPhone: string;
service: string;
staffName: string;
startsAt: string; // ISO 8601
durationMinutes: number;
locationName: string;
}
type MessageType = "confirmation" | "reminder_24h" | "reminder_2h" | "followup" | "cancellation";
function formatDate(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: "America/Los_Angeles", // match your salon's timezone
});
}
const templates: Record<MessageType, (a: Appointment) => string> = {
confirmation: (a) =>
`Hi ${a.clientName.split(" ")[0]}! ✅ You're booked for ${a.service} with ${a.staffName} on ${formatDate(a.startsAt)} at ${a.locationName}. Reply CANCEL to cancel. See you then!`,
reminder_24h: (a) =>
`Hi ${a.clientName.split(" ")[0]}! Just a reminder — your ${a.service} with ${a.staffName} is tomorrow at ${formatDate(a.startsAt)}. Reply CANCEL if you need to cancel.`,
reminder_2h: (a) =>
`See you in 2 hours, ${a.clientName.split(" ")[0]}! Your ${a.service} at ${a.locationName} starts at ${formatDate(a.startsAt)}. Reply CANCEL to cancel.`,
followup: (a) =>
`Thanks for visiting ${a.locationName}, ${a.clientName.split(" ")[0]}! 🙏 We'd love to hear how it went — leave us a quick review: https://g.page/your-salon/review`,
cancellation: (a) =>
`Hi ${a.clientName.split(" ")[0]}, your ${a.service} on ${formatDate(a.startsAt)} has been cancelled. Book again anytime at https://your-salon.com/book`,
};
interface SendResult {
campaignId: string;
recipientCount: number;
}
export async function sendAppointmentSMS(
type: MessageType,
appointment: Appointment
): Promise<SendResult> {
const message = templates[type](appointment);
return pulseRequest<SendResult>("/api/sms/send", {
method: "POST",
body: JSON.stringify({
recipients: [
{
phone: appointment.clientPhone,
name: appointment.clientName,
},
],
message,
}),
});
}What the client receives:
Hi Sarah! ✅ You're booked for Balayage & Blow Dry with Jamie on
Sunday, April 13 at 2:00 PM at Downtown Studio. Reply CANCEL to
cancel. See you then!Python version
from datetime import datetime, timezone
from pulse import pulse # the PulseClient from Step 1
def format_date(iso: str) -> str:
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
return dt.strftime("%A, %B %-d at %-I:%M %p")
TEMPLATES = {
"confirmation": lambda a: (
f"Hi {a['clientName'].split()[0]}! ✅ You're booked for {a['service']} "
f"with {a['staffName']} on {format_date(a['startsAt'])} at {a['locationName']}. "
f"Reply CANCEL to cancel."
),
"reminder_24h": lambda a: (
f"Hi {a['clientName'].split()[0]}! Reminder — your {a['service']} "
f"with {a['staffName']} is tomorrow at {format_date(a['startsAt'])}. "
f"Reply CANCEL if plans change."
),
"reminder_2h": lambda a: (
f"See you in 2 hours, {a['clientName'].split()[0]}! "
f"Your {a['service']} starts at {format_date(a['startsAt'])} "
f"at {a['locationName']}."
),
"followup": lambda a: (
f"Thanks for visiting, {a['clientName'].split()[0]}! 🙏 "
f"Leave us a quick review: https://g.page/your-salon/review"
),
}
def send_appointment_sms(msg_type: str, appointment: dict) -> dict:
message = TEMPLATES[msg_type](appointment)
return pulse.post("/api/sms/send", {
"recipients": [
{"phone": appointment["clientPhone"], "name": appointment["clientName"]}
],
"message": message,
})Step 5 — Schedule the timed reminders
The confirmation fires immediately. The 24h and 2h reminders need to run at a specific time relative to the appointment. Use whatever job queue your stack already has — here are two common options.
Option A: Node.js with BullMQ (Redis)
// lib/scheduler.ts
import { Queue } from "bullmq";
import IORedis from "ioredis";
const connection = new IORedis(process.env.REDIS_URL!);
const reminderQueue = new Queue("appointment-reminders", { connection });
interface Appointment {
id: string;
startsAt: string;
[key: string]: unknown;
}
export async function scheduleReminders(appointment: Appointment) {
const startMs = new Date(appointment.startsAt).getTime();
const now = Date.now();
const jobs = [
{ type: "reminder_24h", delay: startMs - now - 24 * 60 * 60 * 1000 },
{ type: "reminder_2h", delay: startMs - now - 2 * 60 * 60 * 1000 },
{ type: "followup", delay: startMs - now + 30 * 60 * 1000 }, // 30min after
];
for (const job of jobs) {
if (job.delay <= 0) continue; // skip if the window has already passed
await reminderQueue.add(
job.type,
{ type: job.type, appointment },
{ delay: job.delay, jobId: `${appointment.id}-${job.type}` }
);
}
}// workers/reminderWorker.ts
import { Worker } from "bullmq";
import IORedis from "ioredis";
import { sendAppointmentSMS } from "../lib/appointmentSMS";
const connection = new IORedis(process.env.REDIS_URL!);
new Worker(
"appointment-reminders",
async (job) => {
const { type, appointment } = job.data;
await sendAppointmentSMS(type, appointment);
console.log(`Sent ${type} to ${appointment.clientPhone}`);
},
{ connection }
);Option B: Python with Celery + Redis
# tasks.py
from celery import Celery
from datetime import datetime, timezone, timedelta
from pulse import send_appointment_sms
app = Celery("reminders", broker=os.environ["REDIS_URL"])
@app.task
def send_reminder(msg_type: str, appointment: dict):
result = send_appointment_sms(msg_type, appointment)
print(f"Sent {msg_type} → {appointment['clientPhone']} ({result['campaignId']})")
def schedule_reminders(appointment: dict):
starts_at = datetime.fromisoformat(appointment["startsAt"].replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
jobs = [
("reminder_24h", starts_at - timedelta(hours=24)),
("reminder_2h", starts_at - timedelta(hours=2)),
("followup", starts_at + timedelta(minutes=30)),
]
for msg_type, run_at in jobs:
if run_at <= now:
continue # window already passed
send_reminder.apply_async(
args=[msg_type, appointment],
eta=run_at,
task_id=f"{appointment['id']}-{msg_type}",
)Deduplication tip: Pass a deterministic
jobId/task_idderived from the appointment ID and message type. This prevents duplicate reminders if your booking platform fires the webhook more than once.
Step 6 — Handle CANCEL replies (optional but recommended)
Clients who reply CANCEL should actually cancel their appointment — otherwise you're creating confusion. Most booking systems expose a cancellation API endpoint. Wire it up through a PulseSMS inbound webhook.
In Settings → Webhooks in your dashboard, add a webhook for the message.received event:
{
"event": "message.received",
"data": {
"from": "+14155559823",
"body": "CANCEL",
"receivedAt": "2026-04-12T10:15:00Z"
}
}// routes/webhooks.ts (add to existing router)
router.post("/pulse-inbound", async (req, res) => {
res.status(200).json({ received: true });
const { event, data } = req.body;
if (event !== "message.received") return;
const body = data.body.trim().toUpperCase();
if (body !== "CANCEL") return;
// Look up appointment by phone number in your database
const appt = await db.appointments.findUpcomingByPhone(data.from);
if (!appt) return;
// Cancel via your booking platform's API
await bookingClient.cancelAppointment(appt.id);
// Confirm the cancellation back to the client
await pulseRequest("/api/sms/send", {
method: "POST",
body: JSON.stringify({
recipients: [{ phone: data.from }],
message: `Your appointment on ${formatDate(appt.startsAt)} has been cancelled. Book again anytime: https://your-salon.com/book`,
}),
});
});Step 7 — Send a bulk re-engagement campaign
Beyond individual reminders, you can target your full client list — for example, anyone who hasn't booked in 60 days — using the campaigns API.
First, export that segment from your booking system and import it as a contact list in the PulseSMS dashboard (Contacts → Import CSV). Then launch a campaign against that list:
curl -X POST https://pulse-sms.com/api/campaigns \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
-H "Content-Type: application/json" \
-d '{
"name": "Win-back — April 2026",
"message": "Hi {{firstName}}, we miss you! 💇 Book your next appointment this week and get 15% off. Grab a slot: https://your-salon.com/book",
"contactListId": "cl_01winback2026",
"scheduledAt": "2026-04-14T10:00:00Z"
}'Response:
{
"id": "cmp_01winback2026",
"name": "Win-back — April 2026",
"status": "scheduled",
"totalMessages": 183,
"contactList": {
"name": "Inactive clients (60d)",
"contactCount": 183
},
"createdAt": "2026-04-11T09:00:00Z"
}The {{firstName}} template variable is replaced with each contact's first name automatically when the campaign fires.
Complete end-to-end example
Here's the full booking-to-reminder flow wired together:
TypeScript
// server.ts
import express from "express";
import { sendAppointmentSMS } from "./lib/appointmentSMS";
import { scheduleReminders } from "./lib/scheduler";
const app = express();
app.use(express.json());
app.post("/webhooks/booking", async (req, res) => {
const { event, appointment } = req.body;
// Respond immediately to prevent retries
res.status(200).json({ received: true });
if (event === "appointment.created") {
// Confirmation fires right away
await sendAppointmentSMS("confirmation", appointment);
console.log(`Confirmation sent to ${appointment.clientPhone}`);
// Reminders and follow-up queued for later
await scheduleReminders(appointment);
console.log(`Reminders scheduled for appointment ${appointment.id}`);
}
if (event === "appointment.cancelled") {
await sendAppointmentSMS("cancellation", appointment);
}
});
app.listen(3000, () => console.log("Webhook server running on :3000"));Python (Flask)
from flask import Flask, request, jsonify
from pulse import send_appointment_sms
from tasks import schedule_reminders
app = Flask(__name__)
@app.post("/webhooks/booking")
def booking_webhook():
data = request.get_json()
event = data.get("event")
appointment = data.get("appointment")
# Acknowledge immediately
response = jsonify({"received": True})
if event == "appointment.created":
send_appointment_sms("confirmation", appointment)
schedule_reminders(appointment)
elif event == "appointment.cancelled":
send_appointment_sms("cancellation", appointment)
return response
if __name__ == "__main__":
app.run(port=3000)Message template cheat sheet
| Trigger | Timing | Template purpose |
|---|---|---|
appointment.created | Immediate | Booking confirmation with full details |
| Scheduled job | 24h before | Friendly reminder, cancellation opt-out |
| Scheduled job | 2h before | Final heads-up |
| Scheduled job | 30min after | Thank-you + review request |
appointment.cancelled | Immediate | Cancellation acknowledgement + rebook link |
| Manual campaign | Ad-hoc | Win-back, promotions, seasonal offers |
Error reference
| HTTP status | When it happens | What to do |
|---|---|---|
400 | No online device, empty recipient list | Check device status; keep the gateway phone plugged in |
401 | Token expired or bad API key | Re-authenticate via POST /api/auth/token |
403 | No active subscription | Upgrade your plan in the dashboard |
429 | Rate limit hit (10 req/min) or monthly cap | Stagger sends; upgrade if needed |
All errors follow the same shape:
{
"error": "No online devices. Connect at least one device to send SMS.",
"details": "Check the Devices page in your dashboard."
}Next steps
- Webhooks for delivery receipts — subscribe to
message.deliveredandmessage.failedevents under Settings → Webhooks to update appointment records with SMS delivery status. - Two-way confirmations — extend the inbound handler to accept "YES" / "CONFIRM" replies and mark appointments as confirmed in your booking system automatically.
- Multi-location support — register one gateway device per location and tag recipients with
locationIdso messages come from a familiar local number. - Interactive API docs — try every endpoint live at pulse-sms.com/reference.
If you run into anything, hit the Feedback button in your dashboard and we'll get back to you.
Want more SMS gateway tips?
Get practical guides on running free SMS campaigns from your own number — delivered to your inbox.
No credit card required. Unsubscribe any time.