AutomationFlowsWeb Scraping › Appointment Reminder & No-show Rescue Engine

Appointment Reminder & No-show Rescue Engine

Appointment Reminder & No-Show Rescue Engine. Uses httpRequest. Event-driven trigger; 24 nodes.

Event trigger★★★★☆ complexity24 nodesHTTP Request
Web Scraping Trigger: Event Nodes: 24 Complexity: ★★★★☆ Added:

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "Appointment Reminder & No-Show Rescue Engine",
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all",
    "saveManualExecutions": true
  },
  "nodes": [
    {
      "name": "Run Sweep Now",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -1180,
        280
      ],
      "parameters": {},
      "id": "52dc5659-47ee-4a31-9d7e-f5ea14cb705e"
    },
    {
      "name": "Every 15 Minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -1180,
        460
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      },
      "id": "400de2ea-f0d8-4228-888f-e5d480235321"
    },
    {
      "name": "Init Reminder Run",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -960,
        370
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Init Reminder Run\"  (Code node, Run Once for All Items)\n * -------------------------------------------------------------\n * Configuration for one 15-minute sweep. Everything the engine decides is driven\n * from here, so the logic nodes have no per-client settings buried in them.\n */\n\nconst CONFIG = {\n  mockMode: true,               // run with fixtures, no credentials\n  simulateDelivery: true,       // never actually send while this is true\n  locationId: 'REPLACE_WITH_LOCATION_ID',\n\n  // The fixture calendar in \"Mock Appointments\" is written against a fixed\n  // instant: apt_001 is 23.5 hours out so its 24-hour touch is due, apt_002 is\n  // three hours out, and the quiet-hours cases depend on what o'clock it is\n  // where the customer lives. Evaluated against the wall clock those\n  // relationships survive for about an hour, after which the demo plans nothing\n  // and looks broken. While mockMode is on, the sweep is therefore evaluated at\n  // this instant instead of \"now\" \u2014 mock calendar, mock clock \u2014 so the demo\n  // shows the same sweep on any day, and matches samples/expected-output.json.\n  // Ignored the moment mockMode is false: a live sweep always uses the real\n  // clock. Set to null to run the fixtures against real time.\n  mockClockIso: '2026-08-15T10:00:00.000Z',\n\n  // The business's own timezone. Used only as a last resort \u2014 a reminder should\n  // be timed to the customer's clock, not the office's.\n  businessTimeZone: 'America/Los_Angeles',\n\n  // Local-time window in which the customer may be contacted. 21:00-08:00 is\n  // quiet. A send that lands inside it is deferred, not dropped, unless the\n  // deferral would push it past the appointment itself.\n  quietHours: { startHour: 21, endHour: 8 },\n\n  // The sweep runs every 15 minutes; this is how far back it will look for a\n  // touch it should have sent. Generous enough to survive a paused workflow,\n  // tight enough that a 24-hour reminder never goes out 6 hours late.\n  dueWindowMinutes: 90,\n\n  // Reminder ladder, relative to appointment start. Negative = before.\n  reminderTouches: [\n    { key: 't-24h', offsetMinutes: -1440, channels: ['email', 'sms'], template: 'reminder_24h' },\n    { key: 't-3h', offsetMinutes: -180, channels: ['sms'], template: 'reminder_3h' },\n    { key: 't-30m', offsetMinutes: -30, channels: ['sms'], template: 'reminder_30m' },\n  ],\n\n  // No-show rescue, relative to appointment end. Cancels itself the moment the\n  // customer rebooks.\n  noShowTouches: [\n    { key: 'noshow-15m', offsetMinutes: 15, channels: ['sms'], template: 'noshow_immediate' },\n    { key: 'noshow-1d', offsetMinutes: 1440, channels: ['email'], template: 'noshow_day1' },\n    { key: 'noshow-3d', offsetMinutes: 4320, channels: ['email', 'sms'], template: 'noshow_day3' },\n  ],\n\n  // A touch whose send time had already passed when the customer booked is\n  // pointless and looks broken. Someone who books 20 minutes out should not get\n  // a \"your appointment is tomorrow\" text.\n  suppressTouchesEarlierThanBooking: true,\n\n  // Statuses that stop every remaining touch immediately.\n  terminalStatuses: ['cancelled', 'canceled', 'completed', 'rescheduled'],\n};\n\n/**\n * Idempotency ledger. In production this comes from wherever you record sends \u2014\n * a contact custom field, a Data Store, a Postgres table. The shape is a flat\n * list of keys: \"<appointmentId>:<touchKey>:<channel>\".\n *\n * It is passed in rather than looked up inside the planner so the planner stays\n * a pure function and can be tested.\n */\nconst ALREADY_SENT = [\n  'apt_009:t-24h:email',\n  'apt_009:t-24h:sms',\n];\n\n/** The instant this sweep is evaluated at. Real time, unless the fixtures are\n *  driving and a mock clock has been pinned (see CONFIG.mockClockIso). */\nfunction resolveNowMs(config) {\n  if (config.mockMode === true && config.mockClockIso) {\n    const pinned = Date.parse(config.mockClockIso);\n    if (!Number.isNaN(pinned)) return pinned;\n  }\n  return Date.now();\n}\n\nfunction processAll(items) {\n  const nowMs = resolveNowMs(CONFIG);\n  const nowIso = new Date(nowMs).toISOString();\n  const startedAtIso = new Date().toISOString();\n  return [{\n    json: {\n      config: CONFIG,\n      alreadySent: ALREADY_SENT,\n      run: {\n        runId: 'rem_' + nowIso.replace(/[-:.TZ]/g, '').slice(0, 14),\n        startedAt: nowIso,\n        // Wall-clock time the sweep actually ran. Differs from startedAt only\n        // when the mock clock is pinned; useful when reading an execution back.\n        executedAt: startedAtIso,\n        // Every downstream node times itself off this, so one sweep cannot\n        // straddle two instants.\n        nowMs,\n        clock: CONFIG.mockMode === true && CONFIG.mockClockIso ? 'pinned' : 'live',\n      },\n      useLive: CONFIG.mockMode !== true,\n      retry: { attempt: 0, maxAttempts: 5, waitSeconds: 0, history: [] },\n    },\n    pairedItem: items.length ? 0 : undefined,\n  }];\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { CONFIG, ALREADY_SENT, resolveNowMs, processAll };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  return processAll($input.all());\n}"
      },
      "id": "48831c1a-1afc-4c92-8e38-21dfdea1a515"
    },
    {
      "name": "Use Live Calendar?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -740,
        370
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "use-live",
              "leftValue": "={{ $json.useLive }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "8312e218-99d0-41c3-a649-44af6a1b70bc"
    },
    {
      "name": "CRM: Fetch Appointments",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -500,
        240
      ],
      "parameters": {
        "method": "GET",
        "url": "https://services.leadconnectorhq.com/calendars/events",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "specifyQuery": "keypair",
        "queryParameters": {
          "parameters": [
            {
              "name": "locationId",
              "value": "={{ $('Init Reminder Run').item.json.config.locationId }}"
            },
            {
              "name": "startTime",
              "value": "={{ new Date(Date.now() - 5 * 86400000).toISOString() }}"
            },
            {
              "name": "endTime",
              "value": "={{ new Date(Date.now() + 3 * 86400000).toISOString() }}"
            }
          ]
        },
        "sendHeaders": true,
        "specifyHeaders": "keypair",
        "headerParameters": {
          "parameters": [
            {
              "name": "Version",
              "value": "2021-04-15"
            },
            {
              "name": "Accept",
              "value": "application/json"
            }
          ]
        },
        "options": {
          "timeout": 20000,
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          }
        }
      },
      "id": "3a7c9421-7e37-4ebc-8f0d-98a7be265660"
    },
    {
      "name": "Mock Appointments",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -500,
        500
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Mock Appointments\"  (Code node, Run Once for All Items)\n * -------------------------------------------------------------\n * Fixture calendar, in the same shape the live endpoint returns. Every record\n * exists to exercise one decision the planner has to get right:\n *\n *   apt_001  quiet hours \u2014 3am for the customer, must be deferred not sent\n *   apt_002  ordinary 3-hour reminder in a sensible local time\n *   apt_003  30-minute reminder, different timezone again\n *   apt_004  no-show, first rescue touch due\n *   apt_005  no-show but already rebooked \u2014 the whole sequence must cancel\n *   apt_006  cancelled \u2014 every remaining touch must stop\n *   apt_007  booked 10 minutes ago; the 24-hour reminder is in the past\n *   apt_008  do-not-disturb contact\n *   apt_009  24-hour touch already recorded as sent \u2014 must not repeat\n *   apt_010  evening local time, allowed\n *   apt_011  no phone number, must fall back to email\n *   apt_012  emoji in the service name \u2014 turns the SMS into UCS-2\n *   apt_013  quiet hours where deferring would miss the appointment \u2014 must drop\n *   apt_014  no contact timezone at all; must be inferred from the phone\n *\n * Times are absolute, and they only mean anything relative to one instant:\n * 2026-08-15T10:00:00Z. apt_001 is 23.5 hours out so its 24-hour touch is due;\n * apt_002 is three hours out; the quiet-hours cases (apt_001, apt_013) depend\n * on what o'clock it is where that customer lives, which no amount of shifting\n * the calendar forward can preserve. So the clock is pinned to that instant\n * instead \u2014 see CONFIG.mockClockIso in \"Init Reminder Run\". test/run.js freezes\n * Date to the same instant, which is why the two agree.\n */\n\nconst MOCK_APPOINTMENTS = [\n  { id: 'apt_001', status: 'confirmed', startAt: '2026-08-16T09:30:00Z', endAt: '2026-08-16T10:30:00Z',\n    createdAt: '2026-08-01T12:00:00Z', service: 'Annual check-up', staff: 'Dr. Alvarez',\n    contact: { id: 'ct_001', firstName: 'Robert', lastName: 'Chen', phone: '+14155550143',\n      email: 'rob.chen@northsidehvac.example.com', timeZone: 'America/Los_Angeles',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_002', status: 'confirmed', startAt: '2026-08-15T13:00:00Z', endAt: '2026-08-15T13:45:00Z',\n    createdAt: '2026-08-10T09:00:00Z', service: 'Boiler service', staff: 'Marek',\n    contact: { id: 'ct_002', firstName: 'Sarah', lastName: 'Okonkwo', phone: '+447700900461',\n      email: 'sarah.okonkwo@gmail.com', timeZone: 'Europe/London',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_003', status: 'confirmed', startAt: '2026-08-15T10:25:00Z', endAt: '2026-08-15T11:00:00Z',\n    createdAt: '2026-08-12T06:00:00Z', service: 'Dental cleaning', staff: 'Dr. Menon',\n    contact: { id: 'ct_003', firstName: 'Priya', lastName: 'Venkatesan', phone: '+919845012345',\n      email: 'priya.v@brightpathdental.example.com', timeZone: 'Asia/Kolkata',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_004', status: 'no-show', startAt: '2026-08-15T09:00:00Z', endAt: '2026-08-15T09:30:00Z',\n    createdAt: '2026-08-09T04:00:00Z', service: 'Consultation', staff: 'Ibrahim',\n    contact: { id: 'ct_004', firstName: 'Aminath', lastName: 'Rasheed', phone: '+9607712345',\n      email: 'aminath.r@example.com', timeZone: 'Indian/Maldives',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_005', status: 'no-show', startAt: '2026-08-14T09:00:00Z', endAt: '2026-08-14T09:30:00Z',\n    createdAt: '2026-08-01T04:00:00Z', service: 'Consultation', staff: 'Ibrahim',\n    rebookedAppointmentId: 'apt_020',\n    contact: { id: 'ct_005', firstName: 'Daniel', lastName: 'Okafor', phone: '+2348012345678',\n      email: 'daniel.okafor@example.com', timeZone: 'Africa/Lagos',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_006', status: 'cancelled', startAt: '2026-08-16T09:30:00Z', endAt: '2026-08-16T10:00:00Z',\n    createdAt: '2026-08-02T12:00:00Z', service: 'Follow-up', staff: 'Dr. Alvarez',\n    contact: { id: 'ct_006', firstName: 'Karen', lastName: 'Whitfield', phone: '+14155550233',\n      email: 'karen.whitfield@example.com', timeZone: 'America/Los_Angeles',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_007', status: 'confirmed', startAt: '2026-08-16T06:00:00Z', endAt: '2026-08-16T06:30:00Z',\n    createdAt: '2026-08-15T09:50:00Z', service: 'Same-day quote', staff: 'Marek',\n    contact: { id: 'ct_007', firstName: 'Dana', lastName: 'Reyes', phone: '+14155550177',\n      email: '', timeZone: 'America/New_York',\n      smsConsent: true, emailConsent: false, dnd: false } },\n\n  { id: 'apt_008', status: 'confirmed', startAt: '2026-08-15T13:00:00Z', endAt: '2026-08-15T13:30:00Z',\n    createdAt: '2026-08-05T10:00:00Z', service: 'Physio', staff: 'Lena',\n    contact: { id: 'ct_008', firstName: 'Harold', lastName: 'Beckett', phone: '+14155550244',\n      email: 'h.beckett@example.org', timeZone: 'America/Los_Angeles',\n      smsConsent: true, emailConsent: true, dnd: true } },\n\n  { id: 'apt_009', status: 'confirmed', startAt: '2026-08-16T09:30:00Z', endAt: '2026-08-16T10:00:00Z',\n    createdAt: '2026-08-03T12:00:00Z', service: 'Skin consult', staff: 'Dr. Alvarez',\n    contact: { id: 'ct_009', firstName: 'Jennifer', lastName: 'Nakamura', phone: '+61412345678',\n      email: 'jen.nakamura@brightpath.example.com', timeZone: 'Australia/Sydney',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_010', status: 'confirmed', startAt: '2026-08-16T09:00:00Z', endAt: '2026-08-16T09:30:00Z',\n    createdAt: '2026-08-04T12:00:00Z', service: 'Massage', staff: 'Lena',\n    contact: { id: 'ct_010', firstName: 'Lucas', lastName: 'van der Berg', phone: '+61412345699',\n      email: 'lucas@vanderberg-bouw.example.com', timeZone: 'Australia/Sydney',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_011', status: 'confirmed', startAt: '2026-08-16T09:30:00Z', endAt: '2026-08-16T10:00:00Z',\n    createdAt: '2026-08-06T12:00:00Z', service: 'Consultation', staff: 'Dr. Menon',\n    contact: { id: 'ct_011', firstName: '', lastName: 'Petrova', phone: '',\n      email: 'anna.petrova@example.net', timeZone: 'Europe/London',\n      smsConsent: false, emailConsent: true, dnd: false } },\n\n  { id: 'apt_012', status: 'confirmed', startAt: '2026-08-15T13:00:00Z', endAt: '2026-08-15T14:00:00Z',\n    createdAt: '2026-08-07T12:00:00Z', service: 'Spa day \ud83d\udc86 \u2014 full package',\n    staff: 'Lena',\n    contact: { id: 'ct_012', firstName: 'Michael', lastName: 'Johnson', phone: '+14155550188',\n      email: 'mike.johnson@example.com', timeZone: 'Europe/London',\n      smsConsent: true, emailConsent: true, dnd: false } },\n\n  { id: 'apt_013', status: 'confirmed', startAt: '2026-08-15T12:30:00Z', endAt: '2026-08-15T13:00:00Z',\n    createdAt: '2026-08-08T12:00:00Z', service: 'Early appointment', staff: 'Dr. Alvarez',\n    contact: { id: 'ct_013', firstName: 'Igor', lastName: 'Petrov', phone: '+14155550211',\n      email: 'igor.petrov@example.net', timeZone: 'America/Los_Angeles',\n      smsConsent: true, emailConsent: false, dnd: false } },\n\n  { id: 'apt_014', status: 'confirmed', startAt: '2026-08-16T09:30:00Z', endAt: '2026-08-16T10:00:00Z',\n    createdAt: '2026-08-09T12:00:00Z', service: 'Site visit', staff: 'Marek',\n    contact: { id: 'ct_014', firstName: 'Tomas', lastName: 'Kowalski', phone: '+447700900123',\n      email: 'tomas.k@example.com', timeZone: '',\n      smsConsent: true, emailConsent: true, dnd: false } },\n];\n\nfunction processAll(items) {\n  return items.map((item, index) => ({\n    json: {\n      ...item.json,\n      response: {\n        appointments: MOCK_APPOINTMENTS,\n        meta: { total: MOCK_APPOINTMENTS.length, source: 'MOCK_MODE' },\n      },\n    },\n    pairedItem: index,\n  }));\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { MOCK_APPOINTMENTS, processAll };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  return processAll($input.all());\n}"
      },
      "id": "a481c6cd-3e16-419b-b3ca-b04e6f1748b6"
    },
    {
      "name": "Classify Fetch Error",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -260,
        40
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Classify API Error\"  (Code node, Run Once for All Items)\n * --------------------------------------------------------------\n * Wired to the non-2xx branch of the calendar fetch. Identical to the file in\n * demos 01 and 02 apart from this header \u2014 the same three-way decision applies to any API.\n * Turns a raw failure into a decision: retry after N seconds, alert a human, or\n * give up and queue the sweep for manual handling \u2014 never \"fail silently\" and\n * never \"retry forever\".\n *\n * Why this is a Code node and not the HTTP node's built-in retry:\n *\n *   n8n's node-level \"Retry On Fail\" is ignored when \"On Error\" is set to either\n *   Continue option \u2014 the node continues down the error branch on the first\n *   failure instead of retrying (n8n issue #10763). So if you want retries *and*\n *   a graceful error path, you have to own the retry loop yourself. This node\n *   plus a Wait node is that loop.\n *\n * It also does the thing most retry code skips: it reads Retry-After. A 429 that\n * says \"wait 30 seconds\" and gets retried in 2 seconds is not a retry, it is a\n * second offence, and providers extend the block for it.\n */\n\nconst RETRY = {\n  maxAttempts: 5,\n  baseDelaySeconds: 2,     // 2, 4, 8, 16, 32 before jitter\n  maxDelaySeconds: 120,\n  jitterRatio: 0.3,        // \u00b130%, so parallel executions do not retry in lockstep\n};\n\n// Transport-level failures worth retrying \u2014 the request never reached the app.\nconst RETRYABLE_NETWORK_CODES = [\n  'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN',\n  'EPIPE', 'ESOCKETTIMEDOUT', 'ERR_SOCKET_CONNECTION_TIMEOUT',\n];\n\nconst RETRYABLE_STATUSES = [408, 425, 429, 500, 502, 503, 504, 522, 524];\n\n/**\n * n8n wraps HTTP failures differently depending on node version, whether the\n * response was JSON, and whether the failure was transport or application level.\n * Dig the useful bits out of all of them rather than assuming one shape.\n */\nfunction extractError(json) {\n  // Preferred shape: the HTTP node is configured Full Response + Never Error, so\n  // a 429 arrives as an ordinary item with statusCode, headers and body intact.\n  // That is the only configuration in which Retry-After is readable at all \u2014 on\n  // the error output, n8n hands you the error, not the response envelope.\n  const fullResponse = typeof json.statusCode === 'number' && json.headers !== undefined;\n  const container = fullResponse ? json : (json.error || json);\n\n  const status = Number(\n    container.statusCode ?? container.status ?? container.httpCode ??\n    (container.response && (container.response.status ?? container.response.statusCode)) ??\n    (container.context && container.context.statusCode) ??\n    (typeof container.code === 'number' ? container.code : undefined) ?? 0\n  ) || 0;\n\n  const headers =\n    container.headers ||\n    (container.response && container.response.headers) ||\n    (container.context && container.context.headers) || {};\n\n  const body =\n    (fullResponse ? container.body : undefined) ??\n    (container.response && (container.response.body ?? container.response.data)) ??\n    container.body ?? container.data ?? null;\n\n  const networkCode = typeof container.code === 'string' ? container.code\n    : typeof container.errno === 'string' ? container.errno : '';\n\n  const message = String(\n    container.message || container.description ||\n    (body && (body.message || body.error || body.msg)) ||\n    'Unknown error'\n  ).slice(0, 500);\n\n  return { status, headers, body, networkCode, message };\n}\n\n/** Header lookup that does not care about casing, because proxies do not either. */\nfunction header(headers, name) {\n  if (!headers) return undefined;\n  const target = name.toLowerCase();\n  for (const [key, value] of Object.entries(headers)) {\n    if (String(key).toLowerCase() === target) return value;\n  }\n  return undefined;\n}\n\n/**\n * Retry-After is legal in two forms: delay-seconds (\"120\") and an HTTP-date\n * (\"Wed, 21 Oct 2026 07:28:00 GMT\"). Both appear in the wild. Also handles the\n * x-ratelimit-reset variants, which some APIs give as a unix timestamp.\n */\nfunction retryAfterSeconds(headers, nowMs) {\n  const raw = header(headers, 'retry-after');\n  if (raw !== undefined && raw !== null && String(raw).trim() !== '') {\n    const text = String(raw).trim();\n    if (/^\\d+$/.test(text)) return Number(text);\n    const date = new Date(text);\n    if (!isNaN(date.getTime())) {\n      return Math.max(0, Math.ceil((date.getTime() - nowMs) / 1000));\n    }\n  }\n\n  const reset = header(headers, 'x-ratelimit-reset') ?? header(headers, 'ratelimit-reset');\n  if (reset !== undefined && /^\\d+$/.test(String(reset))) {\n    const value = Number(reset);\n    // Ten digits or more is an absolute unix timestamp; anything less is a delta.\n    const seconds = String(value).length >= 10\n      ? Math.ceil((value * 1000 - nowMs) / 1000)\n      : value;\n    if (seconds > 0) return seconds;\n  }\n\n  return null;\n}\n\nfunction backoffSeconds(attempt) {\n  const exponential = RETRY.baseDelaySeconds * Math.pow(2, Math.max(0, attempt - 1));\n  const capped = Math.min(exponential, RETRY.maxDelaySeconds);\n  const jitter = capped * RETRY.jitterRatio * (Math.random() * 2 - 1);\n  return Math.max(1, Math.round(capped + jitter));\n}\n\n/**\n * Returns a decision object. `disposition` is the only field the IF nodes read:\n *   'retry'      -> Wait node, then back to the HTTP node\n *   'alert'      -> notify a human immediately; the whole integration is down\n *   'quarantine' -> this one lead is bad; park it, keep processing the rest\n */\nfunction classify(json, nowMs, runIndex) {\n  const state = json.retry || { attempt: 0, maxAttempts: RETRY.maxAttempts, history: [] };\n\n  // Attempt counting has to survive the item losing its state: when an HTTP node\n  // fails, what arrives on the error output is the error, not necessarily the\n  // item that went in. n8n's $runIndex counts how many times this node has run\n  // inside the current loop, so it is the reliable floor for \"which attempt is\n  // this\". Without it, a retry loop that loses state retries forever.\n  const floor = Number.isFinite(Number(runIndex)) ? Number(runIndex) : 0;\n  const attempt = Math.max(Number(state.attempt || 0), floor) + 1;\n  const maxAttempts = Number(state.maxAttempts || RETRY.maxAttempts);\n\n  const error = extractError(json);\n  const { status, networkCode } = error;\n\n  let category;\n  let disposition;\n  let operatorMessage;\n  let retryable = false;\n\n  if (status === 401 || status === 403) {\n    category = 'auth';\n    disposition = 'alert';\n    operatorMessage = status === 401\n      ? 'CRM rejected the credentials (401). The token has expired or been revoked \u2014 reconnect the credential in n8n. Nothing will sync until this is fixed.'\n      : 'CRM accepted the credentials but refused the action (403). The connected user is missing a scope or the location ID is wrong.';\n  } else if (status === 429) {\n    category = 'rate_limit';\n    retryable = true;\n    operatorMessage = 'Rate limited by the CRM. Backing off and retrying.';\n  } else if (status === 422 || status === 400) {\n    category = 'validation';\n    disposition = 'quarantine';\n    operatorMessage = 'The CRM rejected this payload (' + status + '). This is a data problem with one lead, not an outage \u2014 parked for review. Detail: ' + error.message;\n  } else if (status === 404) {\n    category = 'not_found';\n    disposition = 'quarantine';\n    operatorMessage = 'Endpoint or record not found (404). Check the URL and the location/subaccount ID.';\n  } else if (status === 409) {\n    category = 'conflict';\n    disposition = 'quarantine';\n    operatorMessage = 'Conflict (409) \u2014 the CRM believes this contact already exists. Re-run the search rather than retrying the write.';\n  } else if (RETRYABLE_STATUSES.includes(status)) {\n    category = 'server';\n    retryable = true;\n    operatorMessage = 'CRM returned ' + status + '. Transient server-side failure, retrying.';\n  } else if (RETRYABLE_NETWORK_CODES.includes(networkCode)) {\n    category = 'network';\n    retryable = true;\n    operatorMessage = 'Network failure (' + networkCode + ') before the CRM answered. Retrying.';\n  } else if (status >= 400 && status < 500) {\n    category = 'client';\n    disposition = 'quarantine';\n    operatorMessage = 'CRM returned ' + status + '. Not retryable \u2014 the request itself is wrong.';\n  } else {\n    category = 'unknown';\n    retryable = true;\n    operatorMessage = 'Unclassified failure: ' + error.message + '. Treating as transient.';\n  }\n\n  let waitSeconds = 0;\n  if (retryable) {\n    if (attempt >= maxAttempts) {\n      disposition = 'quarantine';\n      operatorMessage = 'Gave up after ' + maxAttempts + ' attempts. Last failure: ' + operatorMessage;\n    } else {\n      disposition = 'retry';\n      // Honour the server's own instruction when it gives one; it knows better\n      // than our exponential curve does.\n      const serverHint = retryAfterSeconds(error.headers, nowMs);\n      waitSeconds = serverHint !== null\n        ? Math.min(serverHint, RETRY.maxDelaySeconds)\n        : backoffSeconds(attempt);\n      if (serverHint !== null) {\n        operatorMessage += ' Server asked for ' + serverHint + 's, honouring it.';\n      }\n    }\n  }\n\n  const history = Array.isArray(state.history) ? state.history.slice(-9) : [];\n  history.push({\n    attempt,\n    at: new Date(nowMs).toISOString(),\n    status,\n    category,\n    waitSeconds,\n  });\n\n  return {\n    ...json,\n    retry: { attempt, maxAttempts, waitSeconds, history },\n    failure: {\n      category,\n      disposition,\n      status,\n      networkCode,\n      message: error.message,\n      operatorMessage,\n      // Body is capped: a 500 from a CRM sometimes returns an entire HTML error\n      // page, and stuffing that into every execution log is how you fill a disk.\n      responseSnippet: error.body\n        ? String(typeof error.body === 'string' ? error.body : JSON.stringify(error.body)).slice(0, 600)\n        : null,\n    },\n    shouldRetry: disposition === 'retry',\n    shouldAlert: disposition === 'alert',\n    isQuarantined: disposition === 'quarantine',\n  };\n}\n\nfunction processAll(items, runIndex) {\n  const nowMs = Date.now();\n  return items.map((item, index) => ({\n    json: classify(item.json || {}, nowMs, runIndex || 0),\n    pairedItem: index,\n  }));\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = {\n    RETRY, extractError, header, retryAfterSeconds, backoffSeconds, classify, processAll,\n  };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  return processAll($input.all(), typeof $runIndex !== 'undefined' ? $runIndex : 0);\n}"
      },
      "id": "98333b0c-71ab-4e30-bfaf-cad5fc083b8b"
    },
    {
      "name": "Retry Fetch?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -40,
        40
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "should-retry",
              "leftValue": "={{ $json.shouldRetry }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "b966eb6b-96eb-4919-8713-da40ce38647f"
    },
    {
      "name": "Backoff Wait",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        -40,
        -160
      ],
      "parameters": {
        "amount": "={{ $json.retry.waitSeconds }}",
        "unit": "seconds"
      },
      "id": "1153832e-10cb-49be-a729-86ffcf7a8f55"
    },
    {
      "name": "Stop: Calendar Unavailable",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        200,
        -80
      ],
      "parameters": {},
      "id": "01e7831e-32d9-47b1-8456-38291413969a"
    },
    {
      "name": "Plan Sends",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -40,
        370
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Plan Sends\"  (Code node, Run Once for All Items)\n * ------------------------------------------------------\n * Decides which reminders are due right now, for whom, on which channel, and at\n * what local time \u2014 then emits one item per send, plus a decision log for every\n * touch it deliberately did not send.\n *\n * The decision log is not decoration. When a client asks \"why didn't Mrs Chen get\n * her reminder\", the answer needs to be one line with a reason code, not an hour\n * of reading execution history.\n *\n * The hard parts, in order of how often they are got wrong:\n *\n *   TIMEZONES. \"Send at 9am\" means 9am where the customer is. Doing this with\n *   fixed offsets breaks twice a year; doing it with the server's timezone means\n *   your Sydney customers get texted at 3am. This uses Intl with a real IANA\n *   zone, which handles DST because the tz database does.\n *\n *   QUIET HOURS. A send that lands at 2am is deferred to the start of the next\n *   allowed window, not dropped \u2014 unless deferring would push it past the\n *   appointment, in which case it is dropped with that exact reason.\n *\n *   IDEMPOTENCY. The sweep runs every 15 minutes and the due window is 90\n *   minutes wide, so every touch is seen six times. A deterministic key per\n *   (appointment, touch, channel) is what stops the customer getting six texts.\n *\n *   CANCELLATION. A no-show rescue sequence that keeps running after the\n *   customer rebooks is worse than no sequence at all.\n *\n * Requires a Node build with full ICU (n8n's official Docker images have it) \u2014\n * without it, Intl silently falls back to UTC for every zone. The planner checks\n * for this rather than quietly mistiming everything.\n */\n\n// ---------------------------------------------------------------------------\n// Timezone primitives\n// ---------------------------------------------------------------------------\n\n/** Wall-clock parts for an instant in a given IANA zone. */\nfunction localParts(date, timeZone) {\n  const formatter = new Intl.DateTimeFormat('en-US', {\n    timeZone,\n    hour12: false,\n    year: 'numeric', month: '2-digit', day: '2-digit',\n    hour: '2-digit', minute: '2-digit', second: '2-digit',\n    weekday: 'short',\n  });\n  const parts = {};\n  for (const part of formatter.formatToParts(date)) parts[part.type] = part.value;\n  return {\n    year: Number(parts.year),\n    month: Number(parts.month),\n    day: Number(parts.day),\n    // Some ICU builds render midnight as hour \"24\" under hour12:false.\n    hour: Number(parts.hour) % 24,\n    minute: Number(parts.minute),\n    second: Number(parts.second),\n    weekday: parts.weekday,\n  };\n}\n\n/** Minutes that `timeZone` is ahead of UTC at that instant. DST-correct,\n *  because the answer is derived from the zone's own rendering of the time. */\nfunction offsetMinutes(date, timeZone) {\n  const parts = localParts(date, timeZone);\n  const asIfUtc = Date.UTC(parts.year, parts.month - 1, parts.day,\n    parts.hour, parts.minute, parts.second);\n  return Math.round((asIfUtc - date.getTime()) / 60000);\n}\n\n/**\n * Convert a local wall-clock time in `timeZone` back to a UTC instant.\n *\n * Two passes, because the offset you need depends on the instant you are trying\n * to find. The first guess uses the offset at the naive instant; the second\n * corrects it if that guess landed on the other side of a DST transition. This\n * is the step that hand-rolled timezone code almost always misses.\n */\nfunction localToUtc(parts, timeZone) {\n  const naive = Date.UTC(parts.year, parts.month - 1, parts.day,\n    parts.hour, parts.minute || 0, 0);\n  let guess = new Date(naive - offsetMinutes(new Date(naive), timeZone) * 60000);\n  const corrected = new Date(naive - offsetMinutes(guess, timeZone) * 60000);\n  if (corrected.getTime() !== guess.getTime()) guess = corrected;\n  return guess;\n}\n\n/** Cheap sanity check that the runtime actually has timezone data. */\nfunction hasFullIcu() {\n  try {\n    const reference = new Date('2026-01-15T12:00:00Z');\n    return offsetMinutes(reference, 'Asia/Kolkata') === 330;\n  } catch (error) {\n    return false;\n  }\n}\n\n/** Country dialling code -> a representative IANA zone. A crude fallback, used\n *  only when the CRM has no timezone on the contact, and always reported as\n *  inferred so nobody mistakes it for a fact. */\nconst DIAL_CODE_ZONES = [\n  { cc: '960', zone: 'Indian/Maldives' }, { cc: '971', zone: 'Asia/Dubai' },\n  { cc: '966', zone: 'Asia/Riyadh' }, { cc: '353', zone: 'Europe/Dublin' },\n  { cc: '234', zone: 'Africa/Lagos' }, { cc: '91', zone: 'Asia/Kolkata' },\n  { cc: '61', zone: 'Australia/Sydney' }, { cc: '64', zone: 'Pacific/Auckland' },\n  { cc: '65', zone: 'Asia/Singapore' }, { cc: '44', zone: 'Europe/London' },\n  { cc: '49', zone: 'Europe/Berlin' }, { cc: '31', zone: 'Europe/Amsterdam' },\n  { cc: '27', zone: 'Africa/Johannesburg' }, { cc: '63', zone: 'Asia/Manila' },\n];\n\nfunction resolveTimeZone(contact, config) {\n  if (contact.timeZone && contact.timeZone.trim()) {\n    return { zone: contact.timeZone.trim(), source: 'contact_record' };\n  }\n  const digits = String(contact.phone ?? '').replace(/\\D/g, '');\n  if (digits) {\n    const match = DIAL_CODE_ZONES\n      .slice()\n      .sort((a, b) => b.cc.length - a.cc.length)\n      .find((entry) => digits.startsWith(entry.cc));\n    if (match) return { zone: match.zone, source: 'inferred_from_phone_country_code' };\n  }\n  return { zone: config.businessTimeZone, source: 'business_default' };\n}\n\n// ---------------------------------------------------------------------------\n// Quiet hours\n// ---------------------------------------------------------------------------\n\nfunction isQuiet(parts, quietHours) {\n  const { startHour, endHour } = quietHours;\n  if (startHour === endHour) return false;\n  return startHour > endHour\n    ? (parts.hour >= startHour || parts.hour < endHour)  // window crosses midnight\n    : (parts.hour >= startHour && parts.hour < endHour);\n}\n\n/** The next instant at or after `date` that is not inside quiet hours. */\nfunction nextAllowedInstant(date, timeZone, quietHours) {\n  const parts = localParts(date, timeZone);\n  if (!isQuiet(parts, quietHours)) return date;\n\n  const target = { ...parts, hour: quietHours.endHour, minute: 0, second: 0 };\n  // If it is already past the end of the window today, the next opening is\n  // tomorrow. Date.UTC normalises day overflow (32 August becomes 1 September).\n  if (parts.hour >= quietHours.endHour) target.day += 1;\n\n  return localToUtc(target, timeZone);\n}\n\n// ---------------------------------------------------------------------------\n// Planning\n// ---------------------------------------------------------------------------\n\nfunction channelIsUsable(channel, contact) {\n  if (contact.dnd === true) return { usable: false, reason: 'CONTACT_DND' };\n  if (channel === 'sms') {\n    if (!contact.phone) return { usable: false, reason: 'NO_PHONE_NUMBER' };\n    if (contact.smsConsent !== true) return { usable: false, reason: 'NO_SMS_CONSENT' };\n    return { usable: true };\n  }\n  if (channel === 'email') {\n    if (!contact.email) return { usable: false, reason: 'NO_EMAIL_ADDRESS' };\n    if (contact.emailConsent !== true) return { usable: false, reason: 'NO_EMAIL_CONSENT' };\n    return { usable: true };\n  }\n  return { usable: false, reason: 'UNKNOWN_CHANNEL' };\n}\n\nfunction planAppointment(appointment, config, sentKeys, nowMs) {\n  const decisions = [];\n  const sends = [];\n  const contact = appointment.contact || {};\n\n  const push = (touchKey, channel, outcome, reason, extra) => {\n    decisions.push({\n      appointmentId: appointment.id,\n      contactId: contact.id || null,\n      touch: touchKey,\n      channel: channel || null,\n      outcome,\n      reason,\n      ...(extra || {}),\n    });\n  };\n\n  const status = String(appointment.status ?? '').toLowerCase();\n\n  if (config.terminalStatuses.includes(status)) {\n    push('*', null, 'suppressed', 'APPOINTMENT_' + status.toUpperCase());\n    return { sends, decisions };\n  }\n  if (appointment.rebookedAppointmentId) {\n    // The single most important rule in a no-show sequence.\n    push('*', null, 'suppressed', 'ALREADY_REBOOKED',\n      { rebookedAs: appointment.rebookedAppointmentId });\n    return { sends, decisions };\n  }\n  if (contact.dnd === true) {\n    push('*', null, 'suppressed', 'CONTACT_DND');\n    return { sends, decisions };\n  }\n\n  const startMs = Date.parse(appointment.startAt);\n  const endMs = Date.parse(appointment.endAt) || startMs;\n  const createdMs = Date.parse(appointment.createdAt) || 0;\n  if (!startMs) {\n    push('*', null, 'error', 'UNPARSEABLE_START_TIME');\n    return { sends, decisions };\n  }\n\n  const { zone, source: zoneSource } = resolveTimeZone(contact, config);\n\n  const isNoShow = status === 'no-show' || status === 'noshow' || status === 'no_show';\n  const touches = isNoShow ? config.noShowTouches : config.reminderTouches;\n  const anchorMs = isNoShow ? endMs : startMs;\n\n  for (const touch of touches) {\n    const idealMs = anchorMs + touch.offsetMinutes * 60000;\n\n    // Due window: this sweep is responsible for touches that came due within\n    // the last dueWindowMinutes. Anything older was missed while the workflow\n    // was off, and firing it now would be worse than not firing it.\n    if (nowMs < idealMs) {\n      push(touch.key, null, 'not_yet', 'SCHEDULED_FOR_' + new Date(idealMs).toISOString());\n      continue;\n    }\n    // Checked before the due window, because \"this touch was already in the past\n    // when they booked\" is permanent and is the useful answer. \"Outside the due\n    // window\" is transient and would hide it.\n    if (config.suppressTouchesEarlierThanBooking && idealMs < createdMs) {\n      push(touch.key, null, 'skipped', 'BOOKED_AFTER_THIS_TOUCH_WAS_DUE',\n        { bookedAt: appointment.createdAt, touchDueAt: new Date(idealMs).toISOString() });\n      continue;\n    }\n    if (nowMs - idealMs > config.dueWindowMinutes * 60000) {\n      push(touch.key, null, 'skipped', 'OUTSIDE_DUE_WINDOW',\n        { wasDueAt: new Date(idealMs).toISOString(),\n          minutesLate: Math.round((nowMs - idealMs) / 60000) });\n      continue;\n    }\n\n    // --- quiet hours ---\n    const idealDate = new Date(idealMs);\n    const idealLocal = localParts(idealDate, zone);\n    let sendDate = idealDate;\n    let deferred = false;\n\n    if (isQuiet(idealLocal, config.quietHours)) {\n      sendDate = nextAllowedInstant(idealDate, zone, config.quietHours);\n      deferred = true;\n\n      if (!isNoShow && sendDate.getTime() >= startMs) {\n        push(touch.key, null, 'dropped', 'QUIET_HOURS_DEFERRAL_WOULD_MISS_APPOINTMENT', {\n          idealSendAt: idealDate.toISOString(),\n          idealLocalTime: idealLocal.hour + ':' + String(idealLocal.minute).padStart(2, '0'),\n          wouldSendAt: sendDate.toISOString(),\n          appointmentStartsAt: appointment.startAt,\n          timeZone: zone,\n        });\n        continue;\n      }\n    }\n\n    // --- channels ---\n    for (const channel of touch.channels) {\n      const key = appointment.id + ':' + touch.key + ':' + channel;\n\n      if (sentKeys.has(key)) {\n        push(touch.key, channel, 'skipped', 'ALREADY_SENT', { idempotencyKey: key });\n        continue;\n      }\n\n      const usable = channelIsUsable(channel, contact);\n      if (!usable.usable) {\n        push(touch.key, channel, 'skipped', usable.reason);\n        continue;\n      }\n\n      const sendLocal = localParts(sendDate, zone);\n      const startLocal = localParts(new Date(startMs), zone);\n\n      sends.push({\n        idempotencyKey: key,\n        appointmentId: appointment.id,\n        contactId: contact.id,\n        touch: touch.key,\n        template: touch.template,\n        channel,\n        destination: channel === 'sms' ? contact.phone : contact.email,\n        sendAt: sendDate.toISOString(),\n        deferredForQuietHours: deferred,\n        idealSendAt: idealDate.toISOString(),\n        timeZone: zone,\n        timeZoneSource: zoneSource,\n        localSendTime: String(sendLocal.hour).padStart(2, '0') + ':'\n          + String(sendLocal.minute).padStart(2, '0'),\n        // Everything a template can reference. Built here so the renderer stays\n        // a pure string function with no knowledge of appointments.\n        context: {\n          first_name: contact.firstName || '',\n          last_name: contact.lastName || '',\n          service: appointment.service || '',\n          staff: appointment.staff || '',\n          appointment_date: startLocal.year + '-' + String(startLocal.month).padStart(2, '0')\n            + '-' + String(startLocal.day).padStart(2, '0'),\n          appointment_time: String(startLocal.hour).padStart(2, '0') + ':'\n            + String(startLocal.minute).padStart(2, '0'),\n          appointment_weekday: startLocal.weekday,\n          time_zone: zone,\n        },\n      });\n\n      push(touch.key, channel, 'queued', deferred ? 'DEFERRED_OUT_OF_QUIET_HOURS' : 'DUE',\n        { sendAt: sendDate.toISOString(), localSendTime: sendLocal.hour + ':'\n          + String(sendLocal.minute).padStart(2, '0') });\n    }\n  }\n\n  return { sends, decisions };\n}\n\nfunction plan(appointments, config, alreadySent, nowMs) {\n  const sentKeys = new Set(alreadySent || []);\n  const sends = [];\n  const decisions = [];\n\n  for (const appointment of appointments) {\n    const result = planAppointment(appointment, config, sentKeys, nowMs);\n    sends.push(...result.sends);\n    decisions.push(...result.decisions);\n    // Guard against the same key being queued twice inside one sweep, which can\n    // happen if the calendar returns an appointment more than once.\n    for (const send of result.sends) sentKeys.add(send.idempotencyKey);\n  }\n\n  sends.sort((a, b) => a.sendAt.localeCompare(b.sendAt) ||\n    a.idempotencyKey.localeCompare(b.idempotencyKey));\n\n  return {\n    sends,\n    decisions,\n    summary: {\n      appointmentsSeen: appointments.length,\n      sendsQueued: sends.length,\n      deferredForQuietHours: sends.filter((send) => send.deferredForQuietHours).length,\n      suppressed: decisions.filter((entry) => entry.outcome === 'suppressed').length,\n      skipped: decisions.filter((entry) => entry.outcome === 'skipped').length,\n      dropped: decisions.filter((entry) => entry.outcome === 'dropped').length,\n      bySms: sends.filter((send) => send.channel === 'sms').length,\n      byEmail: sends.filter((send) => send.channel === 'email').length,\n      timeZonesInferred: sends.filter((send) =>\n        send.timeZoneSource !== 'contact_record').length,\n    },\n  };\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = {\n    localParts, offsetMinutes, localToUtc, hasFullIcu, resolveTimeZone,\n    isQuiet, nextAllowedInstant, channelIsUsable, planAppointment, plan,\n  };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  const first = $input.all()[0].json;\n\n  // Mock path keeps config on the item; live path replaced it with the API\n  // response, so config comes back from the initialiser.\n  const state = first.config ? first : $('Init Reminder Run').all()[0].json;\n  // Mock: { response }. Live with Full Response on: { statusCode, headers, body }.\n  const response = first.response !== undefined ? first.response\n    : first.body !== undefined ? first.body : first;\n  const appointments = (response && (response.appointments || response.events ||\n    response.data || [])) || [];\n\n  if (!hasFullIcu()) {\n    throw new Error('This n8n runtime has no timezone database (small-icu build). '\n      + 'Every reminder would be timed in UTC, which is worse than not sending them. '\n      + 'Use the official n8n Docker image, or start Node with --icu-data-dir.');\n  }\n\n  // \"Now\" comes from the initialiser so the whole sweep is evaluated at one\n  // instant \u2014 and so the fixture calendar can be run against a pinned clock.\n  const nowMs = state.run && typeof state.run.nowMs === 'number'\n    ? state.run.nowMs : Date.now();\n\n  const result = plan(appointments, state.config, state.alreadySent || [], nowMs);\n\n  return result.sends.length\n    // The decision log goes on the first item only. Copying it onto all of them\n    // multiplies the execution data by the number of sends for no benefit; read\n    // it from $('Plan Sends').first() if you need it downstream.\n    ? result.sends.map((send, index) => ({ json: {\n        ...send,\n        config: state.config,\n        runId: state.run.runId,\n        planSummary: result.summary,\n        hasSends: true,\n        ...(index === 0 ? { decisions: result.decisions } : {}),\n      } }))\n    : [{ json: {\n        runId: state.run.runId,\n        hasSends: false,\n        planSummary: result.summary,\n        decisions: result.decisions,\n      } }];\n}"
      },
      "id": "09712961-b783-49d3-a621-843bd8a45c4b"
    },
    {
      "name": "Anything Due?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        180,
        370
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "has-sends",
              "leftValue": "={{ $json.hasSends }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "bca5fc42-1f17-43c2-ba91-76ceee38806f"
    },
    {
      "name": "Nothing Due This Sweep",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        420,
        520
      ],
      "parameters": {},
      "id": "3fc437f7-22b5-4b5b-a474-39214209aaf1"
    },
    {
      "name": "Render Messages",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        420,
        280
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Render Messages\"  (Code node, Run Once for All Items)\n * -----------------------------------------------------------\n * Turns a planned send into the exact text that goes out, and works out what it\n * will cost.\n *\n * Two things here that a no-code template field cannot do:\n *\n *   FALLBACKS. `{{first_name|there}}` renders \"there\" when the CRM has no first\n *   name. Nothing else in this file is allowed to emit a raw `{{...}}` \u2014 if a\n *   placeholder has no value and no fallback, the send is blocked rather than\n *   texting a customer \"Hi {{first_name}}\". That message has gone out from every\n *   agency at least once and it is always embarrassing.\n *\n *   SEGMENT COUNTING. SMS is billed per segment, not per message, and the rules\n *   change depending on the characters used. One emoji in a service name flips\n *   the whole message from GSM-7 to UCS-2 and cuts the segment size from 160\n *   characters to 70 \u2014 so a 150-character message silently becomes 3 billable\n *   segments instead of 1. Multiply that by 4,000 reminders a month and it is a\n *   real line on the client's invoice. This counts it, and flags anything over\n *   the configured limit before it is sent rather than after it is billed.\n */\n\n// ---------------------------------------------------------------------------\n// Templates. In production these belong in the CRM or a Data Store; inline here\n// so the demo runs standalone.\n// ---------------------------------------------------------------------------\nconst TEMPLATES = {\n  reminder_24h: {\n    sms: 'Hi {{first_name|there}}, reminder: {{service}} with {{staff|our team}} tomorrow at {{appointment_time}}. Reply C to cancel.',\n    email: {\n      subject: 'Your {{service}} appointment tomorrow at {{appointment_time}}',\n      body: 'Hi {{first_name|there}},\\n\\nThis is a reminder of your {{service}} appointment with {{staff|our team}} on {{appointment_weekday}} {{appointment_date}} at {{appointment_time}} ({{time_zone}}).\\n\\nIf you need to change it, just reply to this email.',\n    },\n  },\n  reminder_3h: {\n    sms: 'Hi {{first_name|there}}, see you at {{appointment_time}} today for your {{service}}. Reply C to cancel.',\n  },\n  reminder_30m: {\n    sms: '{{first_name|Hi}} - your {{service}} appointment is in about 30 minutes. See you shortly.',\n  },\n  noshow_immediate: {\n    sms: 'Hi {{first_name|there}}, sorry we missed you today. Want to rebook? Reply YES and we will sort a new time.',\n  },\n  noshow_day1: {\n    email: {\n      subject: 'Sorry we missed you - want to rebook?',\n      body: 'Hi {{first_name|there}},\\n\\nWe had you down for {{service}} with {{staff|our team}} on {{appointment_date}} and did not manage to see you.\\n\\nNo problem at all - reply to this email and we will find a time that works better.',\n    },\n  },\n  noshow_day3: {\n    sms: 'Hi {{first_name|there}}, still happy to get you booked in for {{service}} whenever suits. Reply YES.',\n    email: {\n      subject: 'Still happy to book you in',\n      body: 'Hi {{first_name|there}},\\n\\nThe offer stands - reply and we will get {{service}} in the diary.',\n    },\n  },\n};\n\nconst LIMITS = {\n  maxSmsSegments: 2,     // above this, the send is flagged rather than sent\n};\n\n// ---------------------------------------------------------------------------\n// Template rendering\n// ---------------------------------------------------------------------------\n\n/**\n * Renders {{key}} and {{key|fallback}}. Returns the text plus the list of\n * placeholders that resolved to nothing at all, so the caller can refuse to\n * send rather than leaking template syntax to a customer.\n */\nfunction render(template, context) {\n  const missing = [];\n  const used = [];\n\n  const text = String(template ?? '').replace(/\\{\\{\\s*([a-z0-9_]+)\\s*(?:\\|([^}]*))?\\}\\}/gi,\n    (match, key, fallback) => {\n      const value = context[key];\n      const resolved = value === null || value === undefined || String(value).trim() === ''\n        ? undefined\n        : String(value).trim();\n\n      if (resolved !== undefined) {\n        used.push(key);\n        return resolved;\n      }\n      if (fallback !== undefined) {\n        used.push(key + '(fallback)');\n        return fallback.trim();\n      }\n      missing.push(key);\n      return '';\n    });\n\n  return {\n    // Collapse the double spaces left behind by an empty substitution.\n    text: text.replace(/[ \\t]{2,}/g, ' ').replace(/ +([.,!?])/g, '$1').trim(),\n    missing,\n    used,\n  };\n}\n\n// ---------------------------------------------------------------------------\n// SMS encoding and segment counting\n// ---------------------------------------------------------------------------\n\n// GSM 03.38 basic set. Anything outside it forces the whole message to UCS-2.\nconst GSM7_BASIC = '@\u00a3$\u00a5\u00e8\u00e9\u00f9\u00ec\u00f2\u00c7\\n\u00d8\u00f8\\r\u00c5\u00e5\u0394_\u03a6\u0393\u039b\u03a9\u03a0\u03a8\u03a3\u0398\u039e\u00c6\u00e6\u00df\u00c9 !\"#\u00a4%&\\'()*+,-./0123456789:;<=>?'\n  + '\u00a1ABCDEFGHIJKLMNOPQRSTUVWXYZ\u00c4\u00d6\u00d1\u00dc\u00a7\u00bfabcdefghijklmnopqrstuvwxyz\u00e4\u00f6\u00f1\u00fc\u00e0';\n\n// These cost two GSM-7 characters each because they are escape sequences.\nconst GSM7_EXTENDED = '^{}\\\\[~]|\u20ac';\n\n/**\n * Returns { encoding, characters, segments, perSegment }.\n *\n * Segment sizes are the GSM 03.40 concatenation rules: a single message gets the\n * full payload, but multi-part messages lose room to the UDH header, which is\n * why the second segment is 153 characters and not 160.\n */\nfunction measureSms(text) {\n  const characters = Array.from(String(text ?? ''));   // surrogate-pair safe\n\n  let gsmLength = 0;\n  let isGsm = true;\n  for (const character of characters) {\n    if (GSM7_BASIC.includes(character)) gsmLength += 1;\n    else if (GSM7_EXTENDED.includes(character)) gsmLength += 2;\n    else { isGsm = false; break; }\n  }\n\n  if (isGsm) {\n    const perSegment = gsmLength <= 160 ? 160 : 153;\n    return {\n      encoding: 'GSM-7',\n      characters: gsmLength,\n      perSegment,\n      segments: gsmLength === 0 ? 0 : Math.ceil(gsmLength / perSegment),\n    };\n  }\n\n  // UCS-2 is billed in 16-bit code units, so an emoji outside the BMP counts as\n  // two. String#length already counts code units, which is what we want here.\n  const units = String(text ?? '').length;\n  const perSegment = units <= 70 ? 70 : 67;\n  return {\n    encoding: 'UCS-2',\n    characters: units,\n    perSegment,\n    segments: units === 0 ? 0 : Math.ceil(units / perSegment),\n  };\n}\n\n// ---------------------------------------------------------------------------\nfunction renderSend(send) {\n  const template = TEMPLATES[send.template];\n  if (!template) {\n    return { ...send, renderError: 'NO_TEMPLATE_NAMED_' + send.template, blocked: true };\n  }\n\n  if (send.channel === 'sms') {\n    const source = template.sms;\n    if (!source) {\n      return { ...send, renderError: 'TEMPLATE_HAS_NO_SMS_VARIANT', blocked: true };\n    }\n    const rendered = render(source, send.context);\n    const sms = measureSms(rendered.text);\n\n    const blocked = rendered.missing.length > 0 || sms.segments > LIMITS.maxSmsSegments;\n\n    return {\n      ...send,\n      body: rendered.text,\n      missingPlaceholders: rendered.missing,\n      sms,\n      costNote: sms.encoding === 'UCS-2'\n        ? 'Non-GSM characters (emoji or smart quotes) forced UCS-2, cutting the segment '\n          + 'size from 160 characters to 70. This message bills as ' + sms.segments + ' segments.'\n        : sms.segments + ' segment(s).',\n      blocked,\n      blockedReason: rendered.missing.length\n        ? 'UNRESOLVED_PLACEHOLDERS:' + rendered.missing.join(',')\n        : sms.segments > LIMITS.maxSmsSegments\n          ? 'OVER_SEGMENT_LIMIT:' + sms.segments\n          : null,\n    };\n  }\n\n  const source = template.email;\n  if (!source) {\n    return { ...send, renderError: 'TEMPLATE_HAS_NO_EMAIL_VARIANT', blocked: true };\n  }\n  const subject = render(source.subject, send.context);\n  const body = render(source.body, send.context);\n  const missing = Array.from(new Set([...subject.missing, ...body.missing]));\n\n  return {\n    ...send,\n    subject: subject.text,\n    body: body.text,\n    missingPlaceholders: missing,\n    blocked: missing.length > 0,\n    blockedReason: missing.length ? 'UNRESOLVED_PLACEHOLDERS:' + missing.join(',') : null,\n  };\n}\n\n// --- exported for the local test harness; n8n ignores this block -----------\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { TEMPLATES, LIMITS, render, measureSms, renderSend };\n}\n\n// --- n8n entry point -------------------------------------------------------\nif (typeof $input !== 'undefined') {\n  return $input.all()\n    .filter((item) => item.json && item.json.idempotencyKey)\n    .map((item, index) => ({ json: renderSend(item.json), pairedItem: index }));\n}"
      },
      "id": "ff3d30cf-ea52-4444-b22e-d5aab7739301"
    },
    {
      "name": "Send For Real?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        640,
        280
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "really-send",
              "leftValue": "={{ $json.config.simulateDelivery !== true && $json.blocked !== true }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "9630afce-fc23-49a8-8e64-83a9583aba0b"
    },
    {
      "name": "Simulate Delivery",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        460
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/**\n * NODE: \"Simulate Delivery\"  (Code node, Run Once for All Items)\n * -------------------------------------------------------------\n * Runs instead of the send nodes while `simulateDelivery` is on. Produces the\n * same result shape the real providers do, so **Record Sends** downstream cannot\n * tell the difference and the whole engine can be demonstrated, reviewed and\n * regression-tested without texting anybody.\n *
Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Appointment Reminder & No-Show Rescue Engine. Uses httpRequest. Event-driven trigger; 24 nodes.

Source: https://github.com/ihthicodes/n8n-agency-demos/blob/main/demos/03-appointment-reminder-engine/workflow.json — original creator credit. Request a take-down →

More Web Scraping workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Web Scraping

This workflow listens for an “Approved” label on a Trello card, reads the AI draft bookkeeping JSON from card comments, and posts the corresponding transaction to Xero. It then adds a Xero deep link b

Trello Trigger, HTTP Request
Web Scraping

02_LLM_Pipeline v1.0. Uses executeWorkflowTrigger, httpRequest, seaTable. Event-driven trigger; 65 nodes.

Execute Workflow Trigger, HTTP Request, Sea Table
Web Scraping

This workflow allows you to import any workflow from a file or another n8n instance and map the credentials easily. A multi-form setup guides you through the entire process At the beginning you have t

Execute Command, Read Write File, HTTP Request +3
Web Scraping

[n8n] Advanced URL Parsing and Shortening Workflow - Switchy.io Integration. Uses splitInBatches, stickyNote, httpRequest, html. Event-driven trigger; 56 nodes.

HTTP Request, GitHub, Stop And Error +1
Web Scraping

[](https://youtu.be/c7yCZhmMjtI)

HTTP Request, GitHub, Stop And Error +1