Skip to content
All posts
apideveloperappointmentsSMSsalonsremindersintegration

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.

PulseSMS TeamApril 11, 2026
How to Send Automated SMS Appointment Reminders with PulseSMS and Your Booking System

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

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

bash
curl -X POST https://pulse-sms.com/api/auth/token \
  -H "Content-Type: application/json" \
  -d '{"apiKey": "pk_live_4f8a2c1d9e3b7f6a0c5e2d8b4a9f1c3e"}'

Response:

json
{
  "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:

typescript
// 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

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

  1. Download the PulseSMS Gateway APK from pulse-sms.com/download
  2. Install and open it on the Android phone you want to use as your gateway
  3. In your dashboard go to Devices → Connect device
  4. Scan the QR code with the app
  5. The device appears in the list with status: online within seconds

Verify it's reachable before sending:

bash
curl https://pulse-sms.com/api/devices \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
json
[
  {
    "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:

plaintext
https://your-server.com/webhooks/booking

A typical booking webhook payload looks like this (the exact shape varies by platform, but these fields are almost always present):

json
{
  "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:

typescript
// 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 200 to 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.

typescript
// 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:

plaintext
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

python
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)

typescript
// 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}` }
    );
  }
}
typescript
// 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

python
# 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_id derived from the appointment ID and message type. This prevents duplicate reminders if your booking platform fires the webhook more than once.


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:

json
{
  "event": "message.received",
  "data": {
    "from": "+14155559823",
    "body": "CANCEL",
    "receivedAt": "2026-04-12T10:15:00Z"
  }
}
typescript
// 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:

bash
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:

json
{
  "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

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)

python
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

TriggerTimingTemplate purpose
appointment.createdImmediateBooking confirmation with full details
Scheduled job24h beforeFriendly reminder, cancellation opt-out
Scheduled job2h beforeFinal heads-up
Scheduled job30min afterThank-you + review request
appointment.cancelledImmediateCancellation acknowledgement + rebook link
Manual campaignAd-hocWin-back, promotions, seasonal offers

Error reference

HTTP statusWhen it happensWhat to do
400No online device, empty recipient listCheck device status; keep the gateway phone plugged in
401Token expired or bad API keyRe-authenticate via POST /api/auth/token
403No active subscriptionUpgrade your plan in the dashboard
429Rate limit hit (10 req/min) or monthly capStagger sends; upgrade if needed

All errors follow the same shape:

json
{
  "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.delivered and message.failed events 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 locationId so 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.