{
  "id": "rz2O0VauVNSjO5tw",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Send automated reminders to multiple clients via Telegram using webhook and schedule triggers",
  "tags": [],
  "nodes": [
    {
      "id": "9006229a-197a-46fd-b5b2-a1ad1abccd52",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        560,
        -16
      ],
      "parameters": {
        "color": 3,
        "width": 584,
        "height": 884,
        "content": "## Multi-tenant reminder system\n\n## How it works\nThis workflow has **3 entry points**:\n\n**1. Webhook trigger** \u2014 an external system sends an event (appointment, deadline, order) and the reminder fires immediately based on the tenant's configured rules.\n\n**2. Schedule trigger** \u2014 runs every minute, queries events approaching their deadline window, and sends reminders automatically. Uses idempotency to ensure the same reminder is never sent twice.\n\n**3. Registration form** \u2014 a built-in n8n form to register new tenants with their channel config and message template. No external backend needed.\n\nAfter the template is rendered with the event variables, the workflow routes to the correct channel (currently Telegram, easily extendable to WhatsApp, email, etc.). Every send \u2014 success or error \u2014 is logged to the database.\n\n## Setup steps\n1. Add your **PostgreSQL credentials** to all Postgres nodes (~2 min)\n2. Add your **Telegram credentials** to the Send Message node (~2 min)\n3. Create the required tables using the SQL schema in the sticky note below (~10 min)\n4. Register your first tenant at `/form/multi-tenant-register`\n5. Send events via `POST /webhook/multi-tenant-webhook` with `x-tenant-token` header"
      },
      "typeVersion": 1
    },
    {
      "id": "4d459efd-cd19-45a7-a3ef-2aab53c3850a",
      "name": "Database Schema",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        -16
      ],
      "parameters": {
        "width": 552,
        "height": 884,
        "content": "## Required database schema\n\n```sql\nCREATE TABLE tenants (\n  id SERIAL PRIMARY KEY,\n  name VARCHAR(255) NOT NULL,\n  channel VARCHAR(50) NOT NULL,\n  channel_config JSONB NOT NULL,\n  api_token VARCHAR(255),\n  active BOOLEAN DEFAULT true,\n  created_at TIMESTAMP DEFAULT NOW()\n);\n\nCREATE TABLE tenant_rules (\n  id SERIAL PRIMARY KEY,\n  tenant_id INTEGER REFERENCES tenants(id),\n  trigger_type VARCHAR(20) NOT NULL,\n  timing_minutes_before INTEGER,\n  message_template TEXT NOT NULL,\n  active BOOLEAN DEFAULT true\n);\n\nCREATE TABLE tenant_events (\n  id SERIAL PRIMARY KEY,\n  tenant_id INTEGER REFERENCES tenants(id),\n  entity_name VARCHAR(255),\n  entity_contact VARCHAR(255),\n  event_datetime TIMESTAMP NOT NULL,\n  extra_data JSONB,\n  status VARCHAR(20) DEFAULT 'pending'\n);\n\nCREATE TABLE tenant_reminders_sent (\n  id SERIAL PRIMARY KEY,\n  tenant_id INTEGER,\n  rule_id INTEGER,\n  event_id INTEGER,\n  sent_at TIMESTAMP DEFAULT NOW()\n);\n\nCREATE TABLE tenant_logs (\n  id SERIAL PRIMARY KEY,\n  tenant_id INTEGER,\n  rule_id INTEGER,\n  event_id INTEGER,\n  status VARCHAR(20),\n  message_sent TEXT,\n  error_message TEXT,\n  created_at TIMESTAMP DEFAULT NOW()\n);\n```"
      },
      "typeVersion": 1
    },
    {
      "id": "67d9c4dc-0d45-4a3a-845a-8054ae568403",
      "name": "Webhook flow group",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        880
      ],
      "parameters": {
        "color": 7,
        "width": 1148,
        "height": 524,
        "content": "## Webhook flow\nReceives events from external systems.\nValidates the tenant token via `x-tenant-token` header,\nfetches tenant config from the database,\nand fires the reminder immediately."
      },
      "typeVersion": 1
    },
    {
      "id": "7b2b0ff0-bcd0-4ce0-a5c7-3b982299f275",
      "name": "Schedule flow group",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -224,
        880
      ],
      "parameters": {
        "color": 7,
        "width": 212,
        "height": 260,
        "content": "## Schedule flow\nRuns every minute.\nQueries events within the configured timing window per tenant.\nIdempotency via `tenant_reminders_sent` \u2014 same reminder is never sent twice."
      },
      "typeVersion": 1
    },
    {
      "id": "96a8d879-807a-48ec-8fe1-416b9d208cac",
      "name": "Registration group",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        0,
        1424
      ],
      "parameters": {
        "color": 7,
        "width": 1252,
        "height": 312,
        "content": "## Tenant registration\nBuilt-in n8n form to register new tenants.\nNo external backend needed.\nAccess at `/form/multi-tenant-register`"
      },
      "typeVersion": 1
    },
    {
      "id": "48a46eb1-67d8-4d98-8cf0-16840e4d4a07",
      "name": "Send and log group",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1152,
        800
      ],
      "parameters": {
        "color": 7,
        "width": 760,
        "height": 604,
        "content": "## Send and log\nRoutes to the correct channel based on tenant config.\nLogs every attempt \u2014 success and error \u2014 to the database.\n\nTo add a new channel: duplicate the IF node\nand add the corresponding send node."
      },
      "typeVersion": 1
    },
    {
      "id": "e977f872-9060-4743-b7c1-0274d58bf25f",
      "name": "Every minute - check due events",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        544,
        1232
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes"
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "81af22b5-b58e-4562-9208-c68844895011",
      "name": "Log success to database",
      "type": "n8n-nodes-base.postgres",
      "position": [
        1664,
        1024
      ],
      "parameters": {
        "query": "INSERT INTO tenant_logs (tenant_id, rule_id, event_id, status, message_sent)\nVALUES (\n  {{ $('Render Template').item.json.tenants_id }},\n  {{ $('Render Template').item.json.rule_id }},\n  {{ $('Render Template').item.json.event_id || null }},\n  'success',\n  '{{ $('Render Template').item.json.message }}'\n);",
        "options": {},
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "4fa680a0-ec02-44a9-93ef-963a95de85f0",
      "name": "Mark reminder as sent",
      "type": "n8n-nodes-base.postgres",
      "position": [
        1664,
        832
      ],
      "parameters": {
        "query": "INSERT INTO tenant_reminders_sent (tenant_id, rule_id, event_id)\nVALUES (\n  {{ $('Render Template').item.json.tenants_id }},\n  {{ $('Render Template').item.json.rule_id }},\n  {{ $('Render Template').item.json.event_id || null }}\n);",
        "options": {},
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "a442acce-3f97-4e0d-bf8a-2e3b6de81ce3",
      "name": "Render Template",
      "type": "n8n-nodes-base.code",
      "position": [
        992,
        1136
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const item = $input.item.json;\n\nconst eventDate = new Date(item.event_datetime);\nconst event_time = eventDate.toLocaleTimeString('en-US', {\n  hour: '2-digit',\n  minute: '2-digit',\n  timeZone: 'America/Sao_Paulo'\n});\n\nconst event_date = eventDate.toLocaleDateString('en-US', {\n  day: '2-digit',\n  month: '2-digit',\n  year: 'numeric',\n  timeZone: 'America/Sao_Paulo'\n});\n\nconst variables = {\n  entity_name: item.entity_name,\n  entity_contact: item.entity_contact,\n  tenants_id: item.tenants_id,\n  event_time,\n  event_date,\n  ...item.extra_data\n};\n\nlet message = item.message_template;\nfor (const [key, value] of Object.entries(variables)) {\n  message = message.replaceAll(`{{${key}}}`, value);\n}\n\nreturn {\n  event_id: item.event_id,\n  rule_id: item.rule_id,\n  tenants_id: item.tenants_id,\n  channel: item.channel,\n  channel_config: item.channel_config,\n  message\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "1703a6ab-1e8e-493d-919f-4d2c85f42be0",
      "name": "Log error to database",
      "type": "n8n-nodes-base.postgres",
      "position": [
        1664,
        1232
      ],
      "parameters": {
        "query": "INSERT INTO tenant_logs (tenant_id, rule_id, event_id, status, error_message)\nVALUES (\n  {{ $json.tenants_id }},\n  {{ $json.rule_id }},\n  {{ $json.event_id }},\n  'error',\n  '{{ $json.error }}'\n);",
        "options": {},
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "c8ef902b-34d9-4cf6-98fb-d7209efb25a9",
      "name": "Send reminder via Telegram",
      "type": "n8n-nodes-base.telegram",
      "onError": "continueErrorOutput",
      "position": [
        1440,
        1024
      ],
      "parameters": {
        "text": "={{ $json.message }}",
        "chatId": "={{ $json.channel_config.chat_id }}",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": false,
      "typeVersion": 1.2
    },
    {
      "id": "a961253c-5768-4223-bc3f-3a28bd902e67",
      "name": "Receive event via webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        96,
        1024
      ],
      "parameters": {
        "path": "multi-tenant-webhook",
        "options": {},
        "httpMethod": "POST"
      },
      "typeVersion": 2.1
    },
    {
      "id": "6f926efe-a55f-463d-84cb-002d563c06c7",
      "name": "Validate tenant token",
      "type": "n8n-nodes-base.code",
      "position": [
        320,
        1024
      ],
      "parameters": {
        "jsCode": "const token = $input.item.json.headers['x-tenant-token'];\n\nif (!token) {\n  throw new Error('Missing x-tenant-token header');\n}\n\nreturn {\n  token,\n  entity_name: $input.item.json.body.entity_name,\n  event_datetime: $input.item.json.body.event_datetime,\n  extra_data: $input.item.json.body.extra_data || {}\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "ee1e6760-a1d1-4dd6-9b3d-27be8ed30ab4",
      "name": "Map webhook fields",
      "type": "n8n-nodes-base.set",
      "position": [
        768,
        1024
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "e3a86613-47f7-4b30-9275-53f2549ede8c",
              "name": "tenants_id",
              "type": "number",
              "value": "={{ $json.tenant_id }}"
            },
            {
              "id": "b81a3869-165e-4e41-a4d0-0aa7993533a2",
              "name": "rule_id",
              "type": "number",
              "value": "={{ $json.rule_id }}"
            },
            {
              "id": "2acf59fa-7fbf-4124-ba75-0e76f0e97d8c",
              "name": "channel",
              "type": "string",
              "value": "={{ $json.channel }}"
            },
            {
              "id": "c51302f8-03a2-423d-8cc6-f55274e78d5f",
              "name": "chat_id",
              "type": "string",
              "value": "={{ $json.channel_config.chat_id }}"
            },
            {
              "id": "60390c8b-732a-4701-b028-dec8c7c09ac2",
              "name": "message_template",
              "type": "string",
              "value": "={{ $json.message_template }}"
            },
            {
              "id": "46312d62-5af1-46d5-a742-f5b50a66742b",
              "name": "entity_name",
              "type": "string",
              "value": "={{ $('Validate tenant token').item.json.entity_name }}"
            },
            {
              "id": "a9280f5a-d4d9-4b61-8a53-07917f51660a",
              "name": "event_datetime",
              "type": "string",
              "value": "={{ $('Validate tenant token').item.json.event_datetime }}"
            },
            {
              "id": "f98d1dfb-aced-495f-8b2d-41b8b052f61f",
              "name": "extra_data",
              "type": "object",
              "value": "={{ $('Validate tenant token').item.json.extra_data }}"
            },
            {
              "id": "f6fb5026-5493-49f4-84af-83d270cbc344",
              "name": "channel_config",
              "type": "object",
              "value": "={{ $json.channel_config }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "bd57f3b8-642a-4e5f-9bc4-6d12ab86f06f",
      "name": "Tenant registration form",
      "type": "n8n-nodes-base.formTrigger",
      "position": [
        80,
        1552
      ],
      "parameters": {
        "options": {},
        "formTitle": "Register New Tenant",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Client name",
              "requiredField": true
            },
            {
              "fieldType": "dropdown",
              "fieldLabel": "Channel",
              "fieldOptions": {
                "values": [
                  {
                    "option": "telegram"
                  },
                  {
                    "option": "whatsapp"
                  },
                  {
                    "option": "email"
                  }
                ]
              }
            },
            {
              "fieldLabel": "Chat ID / Contact",
              "requiredField": true
            },
            {
              "fieldLabel": "API Token"
            },
            {
              "fieldType": "dropdown",
              "fieldLabel": "Trigger type",
              "fieldOptions": {
                "values": [
                  {
                    "option": "schedule"
                  },
                  {
                    "option": "webhook"
                  }
                ]
              }
            },
            {
              "fieldType": "number",
              "fieldLabel": "Minutes before event"
            },
            {
              "fieldType": "textarea",
              "fieldLabel": "Message template",
              "requiredField": true
            }
          ]
        },
        "responseMode": "lastNode"
      },
      "typeVersion": 2.3
    },
    {
      "id": "aea01fd8-2a93-4951-aaba-021588b14ef4",
      "name": "Organize form data",
      "type": "n8n-nodes-base.code",
      "position": [
        304,
        1552
      ],
      "parameters": {
        "jsCode": "const item = $input.item.json;\n\nconst channelConfig = JSON.stringify({\n  chat_id: item['Chat ID / Contact']\n});\n\nreturn {\n  name: item['Client name'],\n  channel: item['Channel'],\n  channel_config: channelConfig,\n  api_token: item['API Token'],\n  trigger_type: item['Trigger type'],\n  timing_minutes_before: item['Minutes before event'] || null,\n  message_template: item['Message template']\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "5b53a74a-858f-47fe-978e-684ebc260dbc",
      "name": "Show registration success",
      "type": "n8n-nodes-base.form",
      "position": [
        976,
        1552
      ],
      "parameters": {
        "options": {},
        "operation": "completion",
        "completionTitle": "=Tenant registered! ID: {{ $('Insert tenant').item.json.id }}"
      },
      "typeVersion": 2.3
    },
    {
      "id": "4aeb37bb-2fc3-4569-8393-b7af7d542af3",
      "name": "Fetch events due for reminder",
      "type": "n8n-nodes-base.postgres",
      "position": [
        768,
        1232
      ],
      "parameters": {
        "query": "SELECT \n  e.id as event_id,\n  e.entity_name,\n  e.entity_contact,\n  e.event_datetime,\n  e.extra_data,\n  r.id as rule_id,\n  r.message_template,\n  r.timing_minutes_before,\n  t.channel,\n  t.channel_config,\n  t.id as tenants_id\nFROM tenant_rules r\nJOIN tenants t ON t.id = r.tenant_id\nJOIN tenant_events e ON e.tenant_id = r.tenant_id\nWHERE r.trigger_type = 'schedule'\n  AND r.active = true\n  AND t.active = true\n  AND e.status = 'pending'\n  AND e.event_datetime BETWEEN NOW() + (r.timing_minutes_before * INTERVAL '1 minute') - INTERVAL '5 minutes'\n                            AND NOW() + (r.timing_minutes_before * INTERVAL '1 minute') + INTERVAL '5 minutes'\n  AND NOT EXISTS (\n    SELECT 1 FROM tenant_reminders_sent rs\n    WHERE rs.rule_id = r.id AND rs.event_id = e.id\n  )",
        "options": {},
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "7dcfdf57-bc38-47e3-aa19-95100c9e9426",
      "name": "Fetch tenant config",
      "type": "n8n-nodes-base.postgres",
      "position": [
        544,
        1024
      ],
      "parameters": {
        "query": "SELECT \n  t.id as tenant_id,\n  t.name,\n  t.channel,\n  t.channel_config,\n  r.id as rule_id,\n  r.message_template\nFROM tenants t\nJOIN tenant_rules r ON r.tenant_id = t.id\nWHERE t.api_token = '{{ $json.token }}'\n  AND r.trigger_type = 'webhook'\n  AND r.active = true\n  AND t.active = true",
        "options": {},
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "c45c91c4-585d-45b4-82c2-0fd47b52b283",
      "name": "Insert tenant rule",
      "type": "n8n-nodes-base.postgres",
      "position": [
        752,
        1552
      ],
      "parameters": {
        "query": "INSERT INTO tenant_rules (tenant_id, trigger_type, timing_minutes_before, message_template, active)\nVALUES (\n  {{ $json.id }},\n  '{{ $('Organize form data').item.json.trigger_type }}',\n  {{ $('Organize form data').item.json.timing_minutes_before || 'NULL' }},\n  '{{ $('Organize form data').item.json.message_template }}',\n  true\n);",
        "options": {},
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "f1215f58-6250-43df-8188-207747655186",
      "name": "Insert tenant",
      "type": "n8n-nodes-base.postgres",
      "position": [
        528,
        1552
      ],
      "parameters": {
        "query": "INSERT INTO tenants (name, channel, channel_config, api_token, active)\nVALUES (\n  '{{ $json.name }}',\n  '{{ $json.channel }}',\n  '{{ $json.channel_config }}',\n  '{{ $json.api_token }}',\n  true\n)\nRETURNING id;",
        "options": {},
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "d9402bd4-ea40-451a-a324-0d69abec2c09",
      "name": "No events due - skip",
      "type": "n8n-nodes-base.noOp",
      "position": [
        1440,
        1232
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "093748d1-c55f-4e6c-9be6-b767f6da243b",
      "name": "Route by channel type",
      "type": "n8n-nodes-base.if",
      "position": [
        1216,
        1136
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "822236b6-3165-4d71-ae54-2797e1e6a226",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.channel }}",
              "rightValue": "telegram"
            }
          ]
        }
      },
      "typeVersion": 2.2
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "96bcd4d0-658a-4236-8d66-4e7e9af1a401",
  "connections": {
    "Insert tenant": {
      "main": [
        [
          {
            "node": "Insert tenant rule",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Render Template": {
      "main": [
        [
          {
            "node": "Route by channel type",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Insert tenant rule": {
      "main": [
        [
          {
            "node": "Show registration success",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Map webhook fields": {
      "main": [
        [
          {
            "node": "Render Template",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Organize form data": {
      "main": [
        [
          {
            "node": "Insert tenant",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch tenant config": {
      "main": [
        [
          {
            "node": "Map webhook fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by channel type": {
      "main": [
        [
          {
            "node": "Send reminder via Telegram",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No events due - skip",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate tenant token": {
      "main": [
        [
          {
            "node": "Fetch tenant config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Tenant registration form": {
      "main": [
        [
          {
            "node": "Organize form data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Receive event via webhook": {
      "main": [
        [
          {
            "node": "Validate tenant token",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send reminder via Telegram": {
      "main": [
        [
          {
            "node": "Mark reminder as sent",
            "type": "main",
            "index": 0
          },
          {
            "node": "Log success to database",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log error to database",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch events due for reminder": {
      "main": [
        [
          {
            "node": "Render Template",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every minute - check due events": {
      "main": [
        [
          {
            "node": "Fetch events due for reminder",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}