Every team that adds appointment reminders eventually ships the same bug: a patient gets the same message four times, at 11 pm, on a number that replied STOP two weeks ago.
It is not a difficult problem to reason about. It is a difficult problem to keep reasoning about, because reminders sit at the intersection of four systems — your scheduler, your template provider, your delivery queue and a customer's stated preferences — and each of them has a different idea of "should this be sent".
Here is the shape of a reminder pipeline that survives contact with production.
1. Generate reminders from an immutable schedule
The first decision: when is a reminder created?
Not when you send it — when you decide it should exist. Create reminder records at booking time:
reminder {
id uuid
booking_id uuid
send_at timestamptz -- instant, computed from the business zone
template enum
channel enum
status pending|sent|cancelled|skipped
idempotency_key text unique
attempt_count int
}
Storing them means the booking is the source of truth, not the cron output. Cancel the booking → cancel the reminder rows. Reschedule → regenerate from the new anchor. No orphaned messages, no reminders for appointments that no longer exist.
The idempotency_key is the load-bearing field. Construct it deterministically:
f"{booking_id}:{template}:{send_at_epoch}"
Because it is derived rather than random, any duplicate generation path — a retried job, a re-run after a deploy, a manual backfill — produces the same key and the unique index rejects it. This single constraint eliminates an entire category of "sent twice" reports.
2. Send through a queue, never inline
The API request that creates a booking must never call WhatsApp. It writes the reminder rows and returns.
A worker picks up status = pending AND send_at <= now() and attempts delivery. Why this matters:
- Provider outages don't fail the booking
- Retries are explicit and bounded
- You can drain the queue in order, or prioritise same-day reminders when a provider is slow
- Rate limits become a queue-depth problem, not an error budget problem
Wrap each attempt in a try/catch with a bounded retry policy — say 3 attempts over 15 minutes with exponential backoff — then mark the row skipped with the provider error. A permanently failing number should not be retried forever, and you need the terminal state to report on it.
3. Make the worker idempotent even if the provider isn't
Some providers deduplicate; most do not. If your worker crashes after the provider accepts the message but before you mark it sent, a naive retry sends it twice.
Two mitigations, use both:
- Pass the idempotency key to the provider if it supports one. Many WhatsApp and SMS APIs accept a client reference.
-
Mark before or after, with a state machine. Move
pending → sendingbefore the call andsending → sentafter. On restart, rows stuck insendingare reconciled against provider delivery logs (most expose a lookup by client reference) rather than blindly retried.
Blindly retrying from sending is how you get duplicate messages on the day your deploy goes wrong.
4. Quiet hours are a first-class rule, not a config flag
A reminder computed for 9 am at the clinic might land at 11 pm for a customer in another timezone, or a re-run might push a batch into the night.
Encode quiet hours explicitly:
-
Compute
send_atin the recipient's effective timezone, not the business's — unless they are the same, which you should establish at booking. - Clamp, don't drop. If a message would fall inside quiet hours, move it to the start of the next allowed window rather than discarding it. A slightly late reminder beats no reminder.
-
Never clamp past the appointment. If shifting would put the message after the start time, send it immediately (if allowed) or mark it
skippedwith a reason.
Also decide what happens on the day of the appointment: a "you're next" message at 06:00 is worse than no message.
5. Opt-out is a global kill switch, checked at send time
The single most expensive mistake is checking consent only at enqueue time.
Consent can change between send_at being computed and the worker firing — someone replies STOP, or unsubscribes from settings, while60 messages sit in the queue. So check at send time:
if recipient.opted_out: status = 'skipped', reason='opted_out'
elif recipient.quiet_now(): reschedule to next window
elif channel == 'whatsapp' && !opt_in_confirmed: status='skipped', reason='no_opt_in'
else: send
Three things to keep straight:
- Transactional vs marketing. A booking confirmation may be permitted where a promotional offer is not. Tag every reminder with its category and apply the right rule.
- Channel-level opt-out. Unsubscribing from WhatsApp should not silently disable SMS for security-style messages — unless that's what they asked for.
-
Record the decision.
skippedwith a reason is a report. No row is an invisible failure.
Compliance-wise: for WhatsApp Business, opt-in must be collected before the first message, and every template message needs a visible way out. Check the current WhatsApp Business Messaging Policy before you decide what counts as transactional.
6. Template changes are a deploy, not an edit
Reminder copy lives in provider-approved templates with their own approval lifecycle. Treat a template change like a code change:
- Version templates. A reminder row should record which version it intended to use.
- Never mutate a template in place while messages are pending — you will get a mixed batch.
- Handle rejection. A template that fails review should fail loudly, not silently skip messages for a week.
7. What to measure
Four numbers tell you whether the pipeline is healthy:
- Delivery rate — accepted by provider ÷ attempted
- Duplicate rate — should be structurally zero; if it isn't, your idempotency boundary is wrong
- Skipped by reason — opted-out, quiet hours, invalid number, template rejected. Each one is an actionable bucket
- Reminder → show-up correlation — the actual point of the exercise
Watch the skipped buckets weekly. A spike in invalid_number means your capture form has a bug; a spike in quiet_hours means your time window is wrong for your customers.
The summary
Reminders are not "send a text an hour before". They are a durable job system with a consent check, a timezone rule, a uniqueness constraint and a terminal state for every message. Generate early, enqueue, check consent at send time, idempotency-key everything, and record why each message didn't go out.
If you would rather not maintain any of it, SWIQ ships booking confirmations, day-before reminders and live queue notifications for Indian clinics and salons — see it on a free demo.
For the operational side, our guide to appointment no-show management covers timing and messaging strategy, and this overview of appointment booking software with WhatsApp compares what platforms handle for you.
Top comments (0)