Appointment reminders two hours out, when half of them get cancelled

Decide at send time, not at booking time. A five-minute sweeper, a claim query and one SMS call — plus what a pre-scheduled message costs you when plans change.

The least-effort setup that stays correct is a sweeper: a job that runs every five minutes, asks which appointments start in about two hours and haven’t been reminded, and sends those. Nothing is scheduled at booking time, so a cancellation needs no undo — the row simply stops matching. Infrai gives you both halves on one key, POST /v1/cron/create for the schedule and POST /v1/sms/send for the message, with cron runs free and only the SMS metered.

The alternative everyone reaches for first is to schedule the message the moment the booking is made. It reads as less work, and it is, right up until the customer moves their appointment from Tuesday to Thursday and you now have a scheduled text that must be found and cancelled, an audit trail split between your database and the provider’s, and a bug class where the reminder says 2pm and the booking says 4pm.

Decide late. It’s cheaper.

Three designs, honestly compared

Pre-scheduled at providerDelayed queue messageFive-minute sweeper
Cancellationmust cancel the scheduled messagemust ignore the message on wakenothing to do
Reschedulecancel plus re-schedulepublish a new one, ignore the oldnothing to do
Truth lives inyour DB and the provideryour DB and the queueyour DB
Failure visibilityprovider dashboardqueue statsyour own job runs
Extra moving partsscheduled-message APIa queue and a consumerone cron job
Best whenvolume is huge and bookings rarely changethe wait is minutes, not hoursanything appointment-shaped

Pre-scheduling wins on one axis and it’s a real one: at very high volume, sending nothing but a “schedule this” call at booking time is less machinery than a sweeper polling a table. Twilio’s message scheduling is the mature version of that, and if your bookings almost never change it’s a fine answer.

For a booking app where they do change, the sweeper is fewer parts. Infrai’s SMS route doesn’t support a send_at field at all — that’s a straight limitation next to a specialist, and the sweeper is how you work without one.

The schedule, registered once

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/cron/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "booking-reminder-sweeper",
    "cron_expr": "*/5 * * * *",
    "task_type": "http_url",
    "task_url": "https://app.example.com/internal/reminders/sweep",
    "payload": {"lead_minutes": 120, "window_minutes": 10},
    "timezone": "UTC",
    "overlap_policy": "skip"
  }'

The response confirms the schedule is live rather than merely stored:

{
  "ok": true,
  "data": {
    "job_id": "cron_7bQ1s3Vd9KpLmN2xTfHw4Zc0",
    "status": "active",
    "next_run_at": "2026-07-26T01:35:00Z"
  }
}

Run the sweeper in UTC and keep the timezone question where it belongs — in the message copy, formatted to the customer’s local time. overlap_policy: "skip" means a slow run never gets a second copy of itself piled on top, which matters because two concurrent sweeps over the same window are exactly how people send two texts.

The sweep, with a claim that makes double sends impossible

The window has to be wider than the cron interval or a run that starts late will skip appointments entirely: a five-minute schedule with a ten-minute window gives every booking two chances to be picked up, and the claim column stops the second chance turning into a second text.

// sweep.mjs — Node 22 ESM. Claims due bookings, then sends one SMS each.
import pg from "pg";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

const CLAIM = `
  UPDATE bookings SET reminder_claimed_at = now()
  WHERE id IN (
    SELECT id FROM bookings
    WHERE status = 'confirmed'
      AND reminder_claimed_at IS NULL
      AND starts_at BETWEEN now() + interval '115 minutes' AND now() + interval '125 minutes'
    FOR UPDATE SKIP LOCKED
  )
  RETURNING id, phone, customer_name, starts_at, timezone`;

function localTime(startsAt, timezone) {
  return new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: timezone }).format(startsAt);
}

export async function sweep() {
  const { rows } = await pool.query(CLAIM);
  const sent = [];

  for (const b of rows) {
    const text = `Hi ${b.customer_name}, reminder: your appointment is at ${localTime(b.starts_at, b.timezone)} today. Reply STOP to opt out.`;
    const res = await fetch(`${API}/v1/sms/send`, {
      method: "POST",
      headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
      body: JSON.stringify({ to: b.phone, body: text, from: "AcmeClinic" }),
      signal: AbortSignal.timeout(10_000),
    });
    const json = await res.json().catch(() => ({}));

    if (!res.ok) {
      // Release the claim only for transient faults; a bad number should stay claimed.
      const permanent = /E\.164|must be|invalid/i.test(json.error?.message ?? "");
      if (!permanent) await pool.query("UPDATE bookings SET reminder_claimed_at = NULL WHERE id = $1", [b.id]);
      console.error(`booking ${b.id}: ${json.error?.code} ${json.error?.message}`);
      continue;
    }

    await pool.query("UPDATE bookings SET reminder_message_id = $1 WHERE id = $2", [json.data.message_id, b.id]);
    sent.push(json.data.message_id);
  }
  return { claimed: rows.length, sent: sent.length };
}

A cancellation between the claim and the send is the only race left, and it’s a small one — two hours of notice means the row is almost always cancelled long before the sweep touches it. If you want that window closed too, re-read status inside the loop immediately before the send.

What the send returns

curl -sS -X POST "https://api.infrai.cc/v1/sms/send" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"to": "+14155550142", "body": "Reminder: your appointment is at 15:30 today.", "from": "AcmeClinic"}'
{
  "ok": true,
  "data": {
    "message_id": "msg_2ZhTtleGakhMuXd68qzTrugF",
    "state": "queued",
    "vendor": "tencent_sms",
    "segments": 1,
    "cost_usd": 0.007475,
    "created_at": "2026-07-26T13:28:11Z"
  }
}

segments is the field to watch. Message parts are billed individually, so a reminder that creeps past 160 GSM-7 characters quietly doubles its own cost — trimming “Reply STOP to opt out” down to “Reply STOP” is worth more than it looks at volume.

Watching the job, and standing a message down

Cron runs are auditable, which is how you find out the sweeper has been failing since a deploy:

curl -sS "https://api.infrai.cc/v1/cron/runs/list/cron_7bQ1s3Vd9KpLmN2xTfHw4Zc0" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Each run reports status, fired_at, duration_ms and the http_status your endpoint returned. If a message has been accepted but not yet handed to a carrier — a cancellation arriving seconds after the sweep — POST /v1/sms/cancel/{id} is free and worth trying, though it can only stop what hasn’t gone out yet:

curl -sS -X POST "https://api.infrai.cc/v1/sms/cancel/msg_2ZhTtleGakhMuXd68qzTrugF" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

There is a middle path worth knowing about: publishing the reminder onto a queue with a per-message delay_seconds does work — we confirmed a delayed message isn’t returned by an immediate consume and shows up in the queue’s delayed_count — but the publish response still reports the message as available, so don’t assert on that field, and the queue-level delivery delay set through an update call was ignored in our testing. For a two-hour wait the sweeper is still the simpler thing to reason about.

The cost of a reminder

One confirmed appointment, one message: $0.007475 per message at the rate we verified on 2026-07-26, marked approximate because destination matters, with $2 of free credit on a new account covering roughly 267 messages. Cron jobs, their run history and cancel calls are all free. Get today’s figure and your own headroom from one call:

curl -sS "https://api.infrai.cc/v1/account/balance" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The affordable_uses_hint block in that response prices sms.send against your current balance. Rates drift down over time and discounts run, so the number you read will often be better than the one printed here.

When to buy this instead of building it

If the reminder is the product — a clinic scheduler with two-way confirmations, waitlist backfill and calendar sync — buy a vertical product and stop reading. If you need the message scheduled by the provider rather than by you, Twilio does that natively and Plivo and MessageBird sit in the same bracket for high-volume messaging.

The sweeper earns its place when the reminder is one feature in an app you already own, because the whole of it is one cron job, one table column and one API call — and the queue, the error capture and the invoice for all three are already on the key you’re holding.

References

Browse more sms developer guides