DEV Community

Nitin Doyal
Nitin Doyal

Posted on

Reminder Delivery That Doesn't Spam: Idempotency Keys, Quiet Hours and Honest Opt-Outs

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
}
Enter fullscreen mode Exit fullscreen mode

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}"
Enter fullscreen mode Exit fullscreen mode

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:

  1. Pass the idempotency key to the provider if it supports one. Many WhatsApp and SMS APIs accept a client reference.
  2. Mark before or after, with a state machine. Move pending → sending before the call and sending → sent after. On restart, rows stuck in sending are 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_at in 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 skipped with 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
Enter fullscreen mode Exit fullscreen mode

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. skipped with 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)