{
  "name": "Patient Appointment Reminders + Two-Way Confirm",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 9 * * *"
            }
          ]
        }
      },
      "id": "a1000000-0000-4000-8000-000000000001",
      "name": "Every morning at 09:00",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [-620, -140],
      "notesInFlow": true,
      "notes": "Runs once a day. Adjust the cron if you want a second afternoon pass."
    },
    {
      "parameters": {
        "url": "={{ $env.PMS_BASE_URL }}/appointments?from={{ $now.toISODate() }}&to={{ $now.plus(8, 'days').toISODate() }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {}
      },
      "id": "a1000000-0000-4000-8000-000000000002",
      "name": "Fetch upcoming appointments",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [-400, -140],
      "notesInFlow": true,
      "notes": "Swap for your PMS / FHIR Appointment search. Must return: id, patientName, patientPhone, startsAt, providerName, locationName, appointmentType."
    },
    {
      "parameters": {
        "jsCode": "// Buckets each appointment into a reminder window and builds the message.\n// Only appointments that land exactly in a window get a reminder, so the same\n// patient is never messaged twice for the same stage.\n\nconst WINDOWS = [\n  { key: '7d',  minHours: 166, maxHours: 194 },\n  { key: '48h', minHours: 44,  maxHours: 52  },\n  { key: '3h',  minHours: 2,   maxHours: 4   },\n];\n\nconst CLINIC_NAME = $env.CLINIC_NAME || 'our clinic';\nconst out = [];\n\nfor (const item of $input.all()) {\n  const appt = item.json;\n\n  if (!appt.patientPhone) continue;\n  if (appt.status && ['cancelled', 'completed'].includes(String(appt.status).toLowerCase())) continue;\n\n  const start = new Date(appt.startsAt);\n  const hoursAway = (start.getTime() - Date.now()) / 36e5;\n  const window = WINDOWS.find(w => hoursAway >= w.minHours && hoursAway < w.maxHours);\n  if (!window) continue;\n\n  const when = start.toLocaleString('en-US', {\n    weekday: 'long', month: 'short', day: 'numeric',\n    hour: 'numeric', minute: '2-digit',\n  });\n\n  const firstName = String(appt.patientName || '').split(' ')[0] || 'there';\n\n  const messages = {\n    '7d':  `Hi ${firstName}, this is ${CLINIC_NAME}. You have an appointment with ${appt.providerName} on ${when}. Reply C to confirm, X to cancel, or R to reschedule.`,\n    '48h': `Hi ${firstName}, a reminder of your appointment with ${appt.providerName} on ${when} at ${appt.locationName}. Reply C to confirm, X to cancel, or R to reschedule.`,\n    '3h':  `Hi ${firstName}, see you today at ${when} with ${appt.providerName}. Reply X if you can no longer make it so we can offer the slot to someone waiting.`,\n  };\n\n  out.push({\n    json: {\n      appointmentId: appt.id,\n      to: appt.patientPhone,\n      window: window.key,\n      body: messages[window.key],\n    },\n  });\n}\n\nreturn out;"
      },
      "id": "a1000000-0000-4000-8000-000000000003",
      "name": "Pick reminder window & write message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [-180, -140]
    },
    {
      "parameters": {
        "resource": "sms",
        "operation": "send",
        "from": "={{ $env.CLINIC_SMS_NUMBER }}",
        "to": "={{ $json.to }}",
        "message": "={{ $json.body }}",
        "options": {}
      },
      "id": "a1000000-0000-4000-8000-000000000004",
      "name": "Send reminder SMS",
      "type": "n8n-nodes-base.twilio",
      "typeVersion": 1,
      "position": [60, -140],
      "notesInFlow": true,
      "notes": "Replace with your WhatsApp / email node if you prefer. Keep the reply keywords identical."
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "patient-reply",
        "options": {}
      },
      "id": "a1000000-0000-4000-8000-000000000005",
      "name": "Patient replies (webhook)",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [-620, 180],
      "webhookId": "a1000000-0000-4000-8000-000000000005",
      "notesInFlow": true,
      "notes": "Point your SMS provider's inbound webhook here. This is the half that turns reminders into rescheduling."
    },
    {
      "parameters": {
        "jsCode": "// Reads the patient's reply and turns it into an intent.\n// Deliberately keyword-first: it is predictable, auditable, and works offline.\n\nconst raw = $input.first().json;\nconst body = String(raw.Body || raw.body || raw.message || '').trim().toLowerCase();\nconst from = raw.From || raw.from || raw.sender;\n\nlet intent = 'unknown';\n\nif (/^(c|y|yes|confirm|confirmed|ok|okay)\\b/.test(body)) intent = 'confirm';\nelse if (/^(x|n|no|cancel|can't|cannot)\\b/.test(body)) intent = 'cancel';\nelse if (/^(r|reschedule|move|change|another)\\b/.test(body)) intent = 'reschedule';\nelse if (/^(stop|unsubscribe|quit)\\b/.test(body)) intent = 'optout';\n\nreturn [{ json: { from, body, intent } }];"
      },
      "id": "a1000000-0000-4000-8000-000000000006",
      "name": "Read reply intent",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [-400, 180]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.intent }}",
                    "rightValue": "confirm",
                    "operator": { "type": "string", "operation": "equals" }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Confirmed"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.intent }}",
                    "rightValue": "cancel",
                    "operator": { "type": "string", "operation": "equals" }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Cancelled"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.intent }}",
                    "rightValue": "reschedule",
                    "operator": { "type": "string", "operation": "equals" }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Reschedule"
            }
          ]
        },
        "options": { "fallbackOutput": "extra", "renameFallbackOutput": "Needs a human" }
      },
      "id": "a1000000-0000-4000-8000-000000000007",
      "name": "Route by intent",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [-180, 180]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.PMS_BASE_URL }}/appointments/confirm",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ phone: $json.from, status: 'confirmed' }) }}",
        "options": {}
      },
      "id": "a1000000-0000-4000-8000-000000000008",
      "name": "Mark confirmed in PMS",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [80, 20]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.PMS_BASE_URL }}/appointments/cancel",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ phone: $json.from, status: 'cancelled', releaseSlot: true }) }}",
        "options": {}
      },
      "id": "a1000000-0000-4000-8000-000000000009",
      "name": "Cancel & release the slot",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [80, 180],
      "notesInFlow": true,
      "notes": "This is where the money is. Releasing the slot immediately is what lets the waitlist fill it while it still has value."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.PMS_BASE_URL }}/waitlist/offer-next",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ releasedByPhone: $json.from }) }}",
        "options": {}
      },
      "id": "a1000000-0000-4000-8000-000000000010",
      "name": "Offer slot to waitlist",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [300, 180]
    },
    {
      "parameters": {
        "resource": "sms",
        "operation": "send",
        "from": "={{ $env.CLINIC_SMS_NUMBER }}",
        "to": "={{ $json.from }}",
        "message": "No problem — someone from the front desk will text you shortly with the next available times. If it is urgent, please call us.",
        "options": {}
      },
      "id": "a1000000-0000-4000-8000-000000000011",
      "name": "Acknowledge reschedule",
      "type": "n8n-nodes-base.twilio",
      "typeVersion": 1,
      "position": [80, 340]
    },
    {
      "parameters": {
        "resource": "sms",
        "operation": "send",
        "from": "={{ $env.CLINIC_SMS_NUMBER }}",
        "to": "={{ $env.FRONT_DESK_NUMBER }}",
        "message": "=Unclear patient reply from {{ $json.from }}: \"{{ $json.body }}\" — needs a human.",
        "options": {}
      },
      "id": "a1000000-0000-4000-8000-000000000012",
      "name": "Escalate to front desk",
      "type": "n8n-nodes-base.twilio",
      "typeVersion": 1,
      "position": [80, 500],
      "notesInFlow": true,
      "notes": "Anything the rules cannot classify goes to a person. Never guess on a patient's behalf."
    }
  ],
  "connections": {
    "Every morning at 09:00": {
      "main": [[{ "node": "Fetch upcoming appointments", "type": "main", "index": 0 }]]
    },
    "Fetch upcoming appointments": {
      "main": [[{ "node": "Pick reminder window & write message", "type": "main", "index": 0 }]]
    },
    "Pick reminder window & write message": {
      "main": [[{ "node": "Send reminder SMS", "type": "main", "index": 0 }]]
    },
    "Patient replies (webhook)": {
      "main": [[{ "node": "Read reply intent", "type": "main", "index": 0 }]]
    },
    "Read reply intent": {
      "main": [[{ "node": "Route by intent", "type": "main", "index": 0 }]]
    },
    "Route by intent": {
      "main": [
        [{ "node": "Mark confirmed in PMS", "type": "main", "index": 0 }],
        [{ "node": "Cancel & release the slot", "type": "main", "index": 0 }],
        [{ "node": "Acknowledge reschedule", "type": "main", "index": 0 }],
        [{ "node": "Escalate to front desk", "type": "main", "index": 0 }]
      ]
    },
    "Cancel & release the slot": {
      "main": [[{ "node": "Offer slot to waitlist", "type": "main", "index": 0 }]]
    }
  },
  "pinData": {},
  "settings": { "executionOrder": "v1" },
  "tags": [{ "name": "healthcare" }, { "name": "scheduling" }]
}
