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 →
{
"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 *
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 →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
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
02_LLM_Pipeline v1.0. Uses executeWorkflowTrigger, httpRequest, seaTable. Event-driven trigger; 65 nodes.
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
[n8n] Advanced URL Parsing and Shortening Workflow - Switchy.io Integration. Uses splitInBatches, stickyNote, httpRequest, html. Event-driven trigger; 56 nodes.
[](https://youtu.be/c7yCZhmMjtI)