WhatsApp appointment reminders from your app: a working example (with clock-change-safe timing)

Send WhatsApp appointment reminders from your own code with a REST API. A scheduled job, an approved template, time zones that survive the October clock change, safe retries and CONFIRM / CANCEL / STOP replies.

October 21, 2026 · 8 min read

Appointment reminders are one of the best uses of WhatsApp: short, expected, and answered in seconds. They are also easy to get subtly wrong. The usual bug is time: a job scheduled in UTC sends the reminder at a different local hour once the clocks change. In Europe that happens this weekend. This guide builds a small reminder job in Node.js that avoids the problem, never double-sends, and handles the customer's reply.

SendFromChat is an independent product and is not affiliated with Meta or WhatsApp. It runs on Meta's official WhatsApp Cloud API, and you keep your own WhatsApp Business account. Endpoints, headers and error types below come from our REST API reference, read on 21 Oct 2026.

Not run against a live API key. The reminder job and the reply parser were run locally against a mock server that imitates the documented POST /messages responses (202 accepted, 422 opted_out, 429 warmup_limit). We did not send real WhatsApp messages with this exact code. Use a test key first, and adapt the db calls, which stand in for your own storage.

The short version

  1. Get an appointment-reminder template approved (a Utility template).
  2. Store each appointment as a UTC instant plus the customer's IANA time zone (for example Europe/Amsterdam).
  3. Run a job every few minutes. It finds appointments starting in the next 1 to 24 hours that have no reminder yet and sends one POST /messages each, with an Idempotency-Key.
  4. Show the time in the customer's own zone, and handle CONFIRM, CANCEL and STOP replies by webhook.

Why a UTC cron breaks on Sunday

In 2026, clocks go back overnight into Sunday 25 October in France, and the same day in the UK (in the EU, summer time ends on the last Sunday of October) (Time Out London and Sortir à Paris, read 21 Oct 2026). Central Europe moves from CEST (UTC+2) to CET (UTC+1); the UK moves from BST (UTC+1) to GMT (UTC+0).

A job that is hard-wired to 0 7 * * * in UTC sends at 09:00 in Berlin or Amsterdam now, and at 08:00 from Sunday 25 October (the job runs after the 01:00 UTC change). If your reminder text says "see you tomorrow at 09:30", nothing is wrong with the text, but the message lands an hour earlier in the morning than your clients are used to.

Two habits fix this:

  • Never store or compare local clock times. Store the appointment start as a UTC instant (2026-10-25T09:00:00Z) and the zone as a separate field. Arithmetic on instants, such as "24 hours before the start", is correct on both sides of a clock change.
  • Format local times only for display, using the customer's zone. In Node.js, Intl.DateTimeFormat with a timeZone option does it with no extra library. If you need rules like "send at 18:00 local the evening before", use a date library that knows time zones (for example Luxon or date-fns-tz) and compute that instant per appointment. Do not add a fixed UTC offset yourself.

1. A template for the reminder

A reminder is sent outside the 24-hour customer service window, so it must be an approved template (how the window works). It belongs in the Utility category, because it is about something the customer booked (the rules, explained). For example:

Hi {{1}}, a reminder of your appointment on {{2}} at {{3}}. Reply CONFIRM to keep it or CANCEL to cancel.

It starts and ends with fixed text, which keeps it clear of the formatting problems that get templates rejected. Create it on the dashboard, or ask your assistant to draft and submit it. It is checked against Meta's formatting rules before submission, and approval usually takes minutes. Meta's template library also has ready-made appointment templates in 16 languages.

Only message customers who agreed to WhatsApp updates (how to collect opt-in).

2. The reminder job

Run it every five minutes from whatever scheduler you already use. Because it looks at instants, the schedule itself can stay in UTC and the clock change does not matter.

// jobs/reminders.js: runs on your server only
const API = "https://sendfromchat.com/api/v1";
const HOUR = 60 * 60 * 1000;

// "Sun 25 Oct 10:00" in the customer's own time zone
function whenLabel(startsAtUtc, timeZone) {
  return new Intl.DateTimeFormat("en-GB", {
    timeZone,
    weekday: "short",
    day: "numeric",
    month: "short",
    hour: "2-digit",
    minute: "2-digit",
    hour12: false,
  })
    .format(startsAtUtc)
    .replace(",", "");
}

async function sendReminder(appt) {
  const res = await fetch(`${API}/messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SENDFROMCHAT_KEY}`,
      "Content-Type": "application/json",
      // Same on every retry of this reminder, so a re-run never double-sends.
      "Idempotency-Key": `appt-${appt.id}-reminder-24h`,
    },
    body: JSON.stringify({
      to: appt.phone, // international format, e.g. +31612345678
      template: {
        name: "appointment_reminder",
        language: "en_US",
        variables: [appt.name, whenLabel(appt.startsAt, appt.timeZone), appt.place],
      },
    }),
  });
  const body = await res.json();
  return { ok: res.ok, type: body.error?.type };
}

export async function sendDueReminders(db, now = new Date()) {
  // Instants, not wall-clock times: "start minus 24 h" is right across a clock change.
  const due = await db.findAppointments({
    reminderSent: false,
    startsBetween: [new Date(now.getTime() + 1 * HOUR), new Date(now.getTime() + 24 * HOUR)],
  });

  for (const appt of due) {
    const r = await sendReminder(appt);
    if (r.ok) {
      await db.markReminder(appt.id, "sent");
    } else if (r.type === "opted_out") {
      await db.markReminder(appt.id, "opted_out"); // their choice: never retry
    } else if (r.type === "template_not_approved" || r.type === "invalid_request") {
      await db.markReminder(appt.id, "needs_attention"); // a person should look
    } // anything else (429 warmup_limit, a 5xx): leave it, the next run retries
  }
}

What matters here:

  • The idempotency key comes from the appointment, not the attempt. appt-4821-reminder-24h stays the same on every retry. If your job crashes after the API call but before markReminder, the next run gets the first response back instead of sending a second message. Reusing a key with a different body returns idempotency_mismatch.
  • The window is 1 to 24 hours before the start. A booking made the same morning still gets a reminder, and nothing is reminded twice because reminderSent is checked.
  • Handle errors by type. opted_out and window_closed are WhatsApp's rules, not bugs. warmup_limit means a new number reached today's cap, so the next run tries again. Full list in the REST API reference.
  • Build with a test key. sfc_test_… keys run every check a real send would (plan, opt-outs, template, window) and then stop: nothing is sent, saved or counted. Keep keys on your server.
  • Every template message you start counts toward your plan's monthly allowance, and Meta bills its own per-message charge to your WhatsApp Business account (plans and limits, WhatsApp API pricing).

3. Handle CONFIRM, CANCEL and STOP

The customer's reply arrives as a message.received webhook and opens a 24-hour window, so you can answer with free-form text, no template. Verify the signature and skip repeated event ids first; the webhook guide shows the full endpoint. The reply logic itself is small:

export function parseAnswer(text) {
  const t = String(text ?? "").trim().toLowerCase();
  if (["confirm", "yes", "ok"].includes(t)) return "confirm";
  if (["cancel", "no"].includes(t)) return "cancel";
  return null; // anything else goes to a person
}

// inside your verified webhook handler, for event.type === "message.received"
const { from, text } = event.data;
const answer = event.data.type === "text" ? parseAnswer(text) : null;
if (answer === "confirm") await db.setStatus(from, "confirmed");
if (answer === "cancel") await db.setStatus(from, "cancelled");
// then reply with { to: from, text: "Thanks, you're confirmed." } to POST /messages

Three points:

  • Check type before you read text. Images, voice notes and buttons arrive as other types.
  • Anything you do not recognise should reach a person. "Can I move it to Friday?" is not a CANCEL.
  • STOP is handled for you. SendFromChat treats a reply of STOP as an opt-out, and opted-out numbers are never messaged again until they reply START. You receive contact.opted_out and contact.opted_in webhooks, so mirror them in your own records and stop scheduling reminders for those people.

4. Test it before real clients see it

  1. Use a test key and a template that is already approved.
  2. Create an appointment for yourself about 23 hours away and run the job. A test key returns 200 with "status": "test" and sends nothing.
  3. Run the job again. The appointment is already marked, so nothing happens.
  4. Put an appointment on Sunday 25 October at 09:00 UTC in an Europe/Amsterdam record and check the label: it should read 10:00, because the clocks have gone back by then.
  5. Swap in a live key only when the above looks right.

Ask Claude Code to wire it up

If you build with Claude Code, you can connect SendFromChat and ask it to "add WhatsApp appointment reminders to my app". It can fetch your approved templates and write the integration for your language, and it can store the key in your environment settings without the key appearing in the chat (connect your assistant). Review the code it writes, and check the time handling against the section above.

Things to keep in mind

  • Consent. Send only to people who agreed. For genuine appointment reminders to an existing customer, the contract basis under the GDPR usually fits, but that is not legal advice; see WhatsApp marketing and the GDPR.
  • No-shows are not solved by one message. A reminder helps; a CONFIRM / CANCEL reply that frees the slot helps more.
  • Time zones of the business and the customer can differ. Store the zone per appointment or per customer, not one global setting.

A note for EU and UK customers

SendFromChat stores data in Mumbai, India. Transfers from the EU and UK are covered by the Standard Contractual Clauses and the UK Addendum in our Data Processing Agreement, and the sub-processors are listed publicly. Read it before you decide.

Try it

Start the 30-day free trial (no card), connect a number, and run the job above with a test key from the Developers page. Comparing options? See SendFromChat vs Twilio and using Meta's Cloud API directly.

Sources

Do this from your AI assistant

SendFromChat connects Claude, ChatGPT, Cursor or any MCP client to the WhatsApp Business API — templates, bulk sends, inbox, chatbots and sequences, just by asking. 30 days free, no card.

Start free