AutomationFlowsData & Sheets › Build an Omnichannel Otp Verification Flow

Build an Omnichannel Otp Verification Flow

ByYenire @yenire on n8n.io

How it works

Webhook trigger★★★★★ complexity45 nodesPostgresEmail Send
Data & Sheets Trigger: Webhook Nodes: 45 Complexity: ★★★★★ Added:

This workflow corresponds to n8n.io template #14209 — we link there as the canonical source.

This workflow follows the Emailsend → Postgres recipe pattern — see all workflows that pair these two integrations.

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
{
  "id": "sTHPXMtsLWWknYKf",
  "name": "1. User verification OTP Onmichannel Workflow N8N",
  "tags": [],
  "nodes": [
    {
      "id": "870cf631-17bf-45bf-a865-afcb775b48b6",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        736,
        1360
      ],
      "parameters": {
        "path": "8e947f01-96d1-47ed-bc4d-e28bcc71821a",
        "options": {},
        "httpMethod": "POST"
      },
      "typeVersion": 2.1
    },
    {
      "id": "32ac0aaf-aefd-4259-83af-c4730711c3f7",
      "name": "If",
      "type": "n8n-nodes-base.if",
      "position": [
        1360,
        1360
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "a8ceb413-8575-4879-8849-ce6dd3831d01",
              "operator": {
                "type": "number",
                "operation": "gt"
              },
              "leftValue": "={{ Object.keys($json).length }}",
              "rightValue": 0
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "318e9acf-08ff-4087-b899-f9046bc55e32",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1280,
        896
      ],
      "parameters": {
        "content": "## the user exists\n"
      },
      "typeVersion": 1
    },
    {
      "id": "4f624126-8d43-4161-a36b-eaf0a6a16b83",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1248,
        1856
      ],
      "parameters": {
        "content": "## the user does not exist"
      },
      "typeVersion": 1
    },
    {
      "id": "f124e921-285b-4933-b89c-9aad8f6dcfa8",
      "name": "Execute a SQL query",
      "type": "n8n-nodes-base.postgres",
      "position": [
        1664,
        1632
      ],
      "parameters": {
        "query": "INSERT INTO onboarding_sessions (channel, platform_user_id, step)\nVALUES ($1, $2, 'waiting_for_email')\nON CONFLICT (channel, platform_user_id)\nDO NOTHING;",
        "options": {
          "queryReplacement": "={{ [\n  $json.channel ?? $node[\"Universal normalization\"].json.channel,\n  $json.platform_user_id ?? $node[\"Universal normalization\"].json.platform_user_id\n] }}"
        },
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "b3bb2f0a-a796-4a3a-9b7a-8de5e6638165",
      "name": "If1",
      "type": "n8n-nodes-base.if",
      "position": [
        2256,
        1552
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "9184710a-ce1e-4938-80b9-cf97eb291012",
              "operator": {
                "name": "filter.operator.equals",
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.step }}",
              "rightValue": "waiting_for_email"
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "df899e6e-2829-4824-897a-b38ce6cd1341",
      "name": "waiting_for_otp",
      "type": "n8n-nodes-base.postgres",
      "position": [
        4016,
        288
      ],
      "parameters": {
        "query": "UPDATE onboarding_sessions\nSET step = 'waiting_for_otp'\nWHERE channel = $1\nAND platform_user_id = $2;",
        "options": {
          "queryReplacement": "={{ $node[\"Universal normalization\"].json.channel }}\n{{ $node[\"Universal normalization\"].json.platform_user_id }}"
        },
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "ba17533b-03ac-4216-a042-9fd10c113a77",
      "name": "If2- extracted_email",
      "type": "n8n-nodes-base.if",
      "position": [
        2928,
        304
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "4c27bcc3-d6c0-4311-9a3e-770e7588975d",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $('Extract email').item.json.extracted_email }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "0b532844-bc77-429d-80c0-4d2b42656f87",
      "name": "Send email",
      "type": "n8n-nodes-base.emailSend",
      "position": [
        3792,
        288
      ],
      "parameters": {
        "html": "=<h2>Your verification code</h2>\n\n<p>Your code is:</p>\n\n<h1>{{ $('Generate OTP').item.json.otp_code }}</h1>\n\n<p>This code expires in 5 minutes.</p>\n\n<p>Este c\u00f3digo expira en 5 minutos.</p>",
        "options": {},
        "subject": "Tu c\u00f3digo de verificaci\u00f3n",
        "toEmail": "={{ $('Extract email').item.json.extracted_email }}",
        "fromEmail": "user@example.com"
      },
      "credentials": {
        "smtp": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.1
    },
    {
      "id": "8f1b6e70-2b32-4ea7-aa0c-801e17f54c3f",
      "name": "If2",
      "type": "n8n-nodes-base.if",
      "position": [
        4240,
        288
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "6c058e84-ffa0-4d4b-bac0-e94f57daa328",
              "operator": {
                "name": "filter.operator.equals",
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.step }}",
              "rightValue": "waiting_for_otp"
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "450bbb83-2d89-4236-8a5f-c122fcbd16b6",
      "name": "Execute a SQL query1",
      "type": "n8n-nodes-base.postgres",
      "position": [
        2928,
        2080
      ],
      "parameters": {
        "query": "SELECT *\nFROM user_otps\nWHERE channel = $1\nAND platform_user_id = $2\nAND otp_code = $3\nAND is_used = false\nAND expires_at > now()\nORDER BY created_at DESC\nLIMIT 1;",
        "options": {
          "queryReplacement": "={{ [ $('Universal normalization').item.json.channel, $('Universal normalization').item.json.platform_user_id, (($('Universal normalization').item.json.message || '').trim()) ] }}"
        },
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6,
      "alwaysOutputData": true
    },
    {
      "id": "9ac99506-f4f4-4913-9692-38392c909e26",
      "name": "Execute a SQL query2",
      "type": "n8n-nodes-base.postgres",
      "position": [
        3920,
        2064
      ],
      "parameters": {
        "query": "UPDATE onboarding_sessions\nSET step = 'verified'\nWHERE channel = $1\nAND platform_user_id = $2;",
        "options": {
          "queryReplacement": "={{ [ $('Universal normalization').item.json.channel, $('Universal normalization').item.json.platform_user_id ] }}"
        },
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "57cc8561-a7ad-4b68-bb16-2f9d640e61c5",
      "name": "IF_resend_otp",
      "type": "n8n-nodes-base.if",
      "position": [
        2720,
        1856
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "10e988ab-b186-4e6e-928a-20e5a00c7533",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ /^\\d{4,6}$/.test(($json.message || '').trim()) }}",
              "rightValue": "true"
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "5b96d50b-4395-4243-ab57-6a40cd2d3267",
      "name": "Execute a SQL query3",
      "type": "n8n-nodes-base.postgres",
      "position": [
        1728,
        1248
      ],
      "parameters": {
        "query": "SELECT global_user_id, email\nFROM users\nWHERE global_user_id = $1\nLIMIT 1;",
        "options": {
          "queryReplacement": "={{ $json.global_user_id }}"
        },
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6,
      "alwaysOutputData": true
    },
    {
      "id": "a3eda303-0fd6-4507-b9a9-55846e0c0691",
      "name": "Execute a SQL query5",
      "type": "n8n-nodes-base.postgres",
      "position": [
        3216,
        288
      ],
      "parameters": {
        "query": "UPDATE onboarding_sessions\nSET email = $1,\n    step = 'waiting_for_otp'\nWHERE channel = $2\nAND platform_user_id = $3;",
        "options": {
          "queryReplacement": "={{ [\n  $json.extracted_email,\n  $node[\"Universal normalization\"].json.channel,\n  $node[\"Universal normalization\"].json.platform_user_id\n] }}"
        },
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "d5bfed53-4155-4210-8498-166d3de79e71",
      "name": "If4",
      "type": "n8n-nodes-base.if",
      "position": [
        2688,
        832
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "f4260b1b-2e1d-470f-8d1d-b41751fe5a5d",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $json.extracted_email }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "b1465b3c-179e-4d80-b811-9de62413e731",
      "name": "IF_step- waiting_otp",
      "type": "n8n-nodes-base.if",
      "position": [
        2512,
        2144
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "9d29eb8f-66ea-4d49-a4ac-d012bcc7c52b",
              "operator": {
                "name": "filter.operator.equals",
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.step }}",
              "rightValue": "waiting_for_otp"
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "924f50e9-8d46-4dd8-aeec-78a2fa213710",
      "name": "If email en session",
      "type": "n8n-nodes-base.if",
      "position": [
        4336,
        2064
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "489b46e1-5e8c-4a34-b764-c9190d2ac4a7",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $json.email }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "bf02cbaf-d67f-4038-a262-c0184606b8e5",
      "name": "Upsert user_channel",
      "type": "n8n-nodes-base.postgres",
      "position": [
        4848,
        2048
      ],
      "parameters": {
        "query": "INSERT INTO user_channels (global_user_id, channel, platform_user_id)\nVALUES ($1, $2, $3)\nON CONFLICT (channel, platform_user_id)\nDO UPDATE SET global_user_id = EXCLUDED.global_user_id\nRETURNING global_user_id, channel, platform_user_id;",
        "options": {
          "queryReplacement": "={{ [ $json.global_user_id, $('Universal normalization').item.json.channel, $('Universal normalization').item.json.platform_user_id ] }}"
        },
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "a1e122d2-e5c9-4298-9bc3-9858f5f7030e",
      "name": "Universal normalization",
      "type": "n8n-nodes-base.code",
      "position": [
        944,
        1360
      ],
      "parameters": {
        "jsCode": "// Tomamos el body real del webhook\nconst data = $json.body || {};\n\nlet channel = null;\nlet platform_user_id = null;\nlet message = null;\n\n// Caso Telegram (simulado simple)\nif (data.message) {\n  channel = \"telegram\";\n  platform_user_id = data.message.from?.id?.toString();\n  message = data.message.text;\n}\n\n// Caso WhatsApp (Meta Cloud API estructura real)\nelse if (data.entry) {\n  channel = \"whatsapp\";\n  platform_user_id = data.entry[0].changes[0].value.messages[0].from;\n  message = data.entry[0].changes[0].value.messages[0].text?.body;\n}\n\n// Caso Email simple\nelse if (data.from && data.subject) {\n  channel = \"email\";\n  platform_user_id = data.from;\n  message = data.text || data.body;\n}\n\n// Fallback\nelse {\n  channel = \"unknown\";\n  platform_user_id = \"unknown\";\n  message = JSON.stringify(data);\n}\n\nreturn [{\n  channel,\n  platform_user_id,\n  message\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "4cd10c7d-62f7-4b15-9480-f6e7c6577c31",
      "name": "Check if the channel already exists",
      "type": "n8n-nodes-base.postgres",
      "position": [
        1152,
        1360
      ],
      "parameters": {
        "query": "SELECT global_user_id\nFROM user_channels\nWHERE channel = $1\nAND platform_user_id = $2;",
        "options": {
          "queryReplacement": "={{ $json.channel }} {{ $json.platform_user_id }}"
        },
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6,
      "alwaysOutputData": true
    },
    {
      "id": "2c85064f-863e-407c-827c-14922c94bbf9",
      "name": "Channel already verified",
      "type": "n8n-nodes-base.set",
      "position": [
        2000,
        1248
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "51e7da67-e385-4551-9558-a4d1ab99a67d",
              "name": "response_message",
              "type": "string",
              "value": "={{ `\u2705 Channel already verified for ${$json.email}. Continuamos con tu perfil.` }}"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "5211c7a7-472c-4da3-8cd1-06b766a18968",
      "name": "Search onboarding_session",
      "type": "n8n-nodes-base.postgres",
      "position": [
        1984,
        1632
      ],
      "parameters": {
        "query": "SELECT step, email\nFROM onboarding_sessions\nWHERE channel = $1\nAND platform_user_id = $2;",
        "options": {
          "queryReplacement": "={{ [   $node[\"Universal normalization\"].json.channel,   $node[\"Universal normalization\"].json.platform_user_id ] }}"
        },
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "fbb4941f-3b12-4871-8ab4-57db409d046a",
      "name": "Extract email",
      "type": "n8n-nodes-base.code",
      "position": [
        2480,
        832
      ],
      "parameters": {
        "jsCode": "const message = $node[\"Universal normalization\"].json.message;\n\n// Robust Regex to detect emails\nconst emailMatch = message\n  ? message.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i)\n  : null;\n\nreturn [{\n  ...$json,\n  extracted_email: emailMatch ? emailMatch[0].toLowerCase() : null\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "aba11586-756c-455d-a229-30ce2870366b",
      "name": "Greeting 1 - send your email",
      "type": "n8n-nodes-base.set",
      "position": [
        2800,
        1008
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "90cba22b-1b98-4e69-9a8f-27db89359e74",
              "name": "response_message",
              "type": "string",
              "value": "\ud83d\udc4b Welcome to Zyntha \u2615 To continue, I need your email address to create or verify your profile."
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "c8913fe3-9a74-49af-8bdf-94ba27ffb10d",
      "name": "Generate OTP",
      "type": "n8n-nodes-base.code",
      "position": [
        3440,
        288
      ],
      "parameters": {
        "jsCode": "const otp = Math.floor(100000 + Math.random() * 900000).toString();\n\nreturn [{\n  ...$json,\n  otp_code: otp,\n  otp_expires_at: new Date(Date.now() + 5 * 60 * 1000) // 5 minutos\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "647aa9aa-aecc-4836-9e83-c3d5d8c968ea",
      "name": "Save OTP",
      "type": "n8n-nodes-base.postgres",
      "position": [
        3616,
        288
      ],
      "parameters": {
        "query": "INSERT INTO user_otps (channel, platform_user_id, otp_code, expires_at)\nVALUES ($1, $2, $3, $4);",
        "options": {
          "queryReplacement": "={{ $node[\"Universal normalization\"].json.channel }}\n{{ $node[\"Universal normalization\"].json.platform_user_id }}\n{{ $json.otp_code }}\n{{ $json.otp_expires_at }}"
        },
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "2cf8c2a4-0f5c-438c-9aeb-fb4d061506f7",
      "name": "response_message1 - verification code sent",
      "type": "n8n-nodes-base.set",
      "position": [
        4512,
        416
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "90cba22b-1b98-4e69-9a8f-27db89359e74",
              "name": "response_message",
              "type": "string",
              "value": "I sent you a verification code. Enter it here to continue."
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "b1f87e35-0a19-4331-8d65-eb6d8eab28cd",
      "name": "IF_code_exists?",
      "type": "n8n-nodes-base.if",
      "position": [
        3264,
        2080
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "7acf0a8e-7ae4-4124-a27c-6ee01b140038",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $json.id }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "f9f76032-3329-4e84-9abd-cc7a09da59a2",
      "name": "code does not exist",
      "type": "n8n-nodes-base.set",
      "position": [
        3328,
        2464
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "90cba22b-1b98-4e69-9a8f-27db89359e74",
              "name": "response_message",
              "type": "string",
              "value": "\u274c Invalid code. Please try again with the correct code."
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "bcc811a6-d8ab-481e-9c1c-8fc92cb00d90",
      "name": "valid OTP",
      "type": "n8n-nodes-base.postgres",
      "position": [
        3696,
        2064
      ],
      "parameters": {
        "query": "UPDATE user_otps\nSET is_used = true\nWHERE id = $1;",
        "options": {
          "queryReplacement": "={{ $('Execute a SQL query1').item.json.id }}"
        },
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "b9ef2797-fd43-4faf-bf9a-24eb24fd5c49",
      "name": "Get verified session email",
      "type": "n8n-nodes-base.postgres",
      "position": [
        4112,
        2064
      ],
      "parameters": {
        "query": "SELECT email\nFROM onboarding_sessions\nWHERE channel = $1\nAND platform_user_id = $2\nLIMIT 1;",
        "options": {
          "queryReplacement": "={{ [ $('Universal normalization').item.json.channel, $('Universal normalization').item.json.platform_user_id ] }}"
        },
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6,
      "alwaysOutputData": true
    },
    {
      "id": "514fc91c-ad13-461a-b77e-07eb259bdb91",
      "name": "Missing email response",
      "type": "n8n-nodes-base.set",
      "position": [
        4592,
        2400
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "7616b83a-63f1-41b4-a625-9db26036d52e",
              "name": "response_message",
              "type": "string",
              "value": "\u26a0\ufe0f I couldn\u2019t find the session email. Please send your email to continue."
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "fb6eb345-fe1b-4898-8b51-4b7a2ad43cf4",
      "name": "Upsert user by email",
      "type": "n8n-nodes-base.postgres",
      "position": [
        4576,
        2048
      ],
      "parameters": {
        "query": "INSERT INTO users (email, verified)\nVALUES ($1, true)\nON CONFLICT (email)\nDO UPDATE SET verified = true, updated_at = now()\nRETURNING global_user_id, email;",
        "options": {
          "queryReplacement": "={{ $json.email }}"
        },
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "8d44f896-f774-4853-a82d-ad5be67b13b0",
      "name": "Verification completed response",
      "type": "n8n-nodes-base.set",
      "position": [
        5152,
        2048
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "7a0603f0-8c66-4e82-abcd-5c797a46301a",
              "name": "response_message",
              "type": "string",
              "value": "\u2705 Verification completed. This channel has been linked to your user."
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "922a8ec9-3581-421e-973d-1209bfa0f4fb",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        96,
        1136
      ],
      "parameters": {
        "width": 544,
        "height": 608,
        "content": "## Omnichannel OTP Verification & User Onboarding\n\n### How it works\nThis workflow centralizes user verification across WhatsApp, Telegram, and email using OTP validation.\n\nIt detects the channel, manages onboarding sessions, generates secure OTP codes, and links verified channels to a single global user identity.\n\n### How to set up\n1. Import the workflow into n8n.\n2. Configure Postgres or Supabase credentials.\n3. Download and create the required database tables (users, user_channels, user_otps, onboarding_sessions).\n4. Connect email and messaging channel credentials.\n5. Test with new and returning users.\n\n### Customization\n\u2022 Adjust OTP expiration time.\n\u2022 Modify onboarding steps or validation logic.\n\u2022 Add additional communication channels."
      },
      "typeVersion": 1
    },
    {
      "id": "6c92564b-02cf-47a8-9881-4ce41dd45f2c",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        720,
        1152
      ],
      "parameters": {
        "color": 7,
        "width": 752,
        "height": 512,
        "content": "## Section 1 \u2014 Channel Detection & Normalization\n\nReceives incoming requests from multiple platforms and identifies the communication channel.\n\nNormalizes user data into a unified structure before checking if the channel is already linked."
      },
      "typeVersion": 1
    },
    {
      "id": "55666d14-9851-4b68-b504-34c708dcc145",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1600,
        1024
      ],
      "parameters": {
        "color": 7,
        "width": 672,
        "height": 384,
        "content": "## Section 2 \u2014 Existing Channel Validation\n\n- Checks if the incoming channel is already associated with a verified user.\n\n- If found, skips the verification process and continues with the existing profile."
      },
      "typeVersion": 1
    },
    {
      "id": "0c7ce79c-bc41-447b-a17c-f8e2434796a8",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1600,
        1472
      ],
      "parameters": {
        "color": 7,
        "width": 624,
        "height": 384,
        "content": "## Section 3 \u2014 Session Initialization\n\n- Creates or retrieves the onboarding session for the current channel.\n\n- Tracks the current step of the verification process."
      },
      "typeVersion": 1
    },
    {
      "id": "0c099410-84a3-46c3-95a2-5671b2bc88f1",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2304,
        576
      ],
      "parameters": {
        "color": 7,
        "width": 704,
        "height": 608,
        "content": "## Section 4 \u2014 Email Extraction & Validation\n\n- Extracts a valid email from the user message using regex detection.\n\n- If no email is found, prompts the user to provide one before continuing."
      },
      "typeVersion": 1
    },
    {
      "id": "b8f1b027-e7b3-401d-86c9-45a26752a4cd",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3152,
        64
      ],
      "parameters": {
        "color": 7,
        "width": 1568,
        "height": 576,
        "content": "## Section 5 \u2014 OTP Generation & Delivery\n\n- Generates a secure one-time password and stores it with an expiration timestamp.\n\n- Sends the OTP via email and updates the session to waiting_for_otp.\n\n- A confirmation message is sent to the email used for delivering the OTP."
      },
      "typeVersion": 1
    },
    {
      "id": "37e80802-135a-419c-968c-23ed5c222b4e",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2480,
        1648
      ],
      "parameters": {
        "color": 7,
        "width": 1088,
        "height": 1072,
        "content": "## Section 4 \u2014 Email Extraction & Validation\n\n- Extracts a valid email from the user message using regex detection.\n\n- If no email is found, prompts the user to provide one before continuing.\n\n- A message is sent to the user if the code does not exist, asking them to verify it and send it again."
      },
      "typeVersion": 1
    },
    {
      "id": "3b112758-2051-4a1d-85b7-cd5ebf9210a8",
      "name": "Sticky Note9",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3664,
        1728
      ],
      "parameters": {
        "color": 7,
        "width": 1760,
        "height": 976,
        "content": "## Section 7 \u2014 Identity Consolidation\n\n- Marks the OTP as used and updates the session as verified in the Supabase database.\n\n- Links the verified channel to a single global user ID to ensure omnichannel continuity.\n\n- A confirmation message is sent via email if the verification has been completed and successfully linked to the user."
      },
      "typeVersion": 1
    },
    {
      "id": "fac105f0-454d-4905-8d2d-5322c67b897e",
      "name": "Sticky Note10",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3792,
        2880
      ],
      "parameters": {
        "color": 3,
        "width": 544,
        "height": 272,
        "content": "## DATABASE REQUIRED\n\nThis workflow depends on predefined Postgres tables.\n\nYou must create users, user_channels, user_otps, and onboarding_sessions before running the flow."
      },
      "typeVersion": 1
    },
    {
      "id": "5c4adad1-c940-427d-a7d5-1d3b6eba74e3",
      "name": "Sticky Note11",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3408,
        -272
      ],
      "parameters": {
        "color": 3,
        "width": 304,
        "height": 208,
        "content": "## Security Notice\n- Do not log OTP codes in production environments.\n\n- Always define an expiration time and mark codes as used after validation."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "02aace02-c27d-4c6b-bde2-d9df86bef1fc",
  "connections": {
    "If": {
      "main": [
        [
          {
            "node": "Execute a SQL query3",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Execute a SQL query",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If1": {
      "main": [
        [
          {
            "node": "Extract email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "IF_step- waiting_otp",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If2": {
      "main": [
        [],
        [
          {
            "node": "response_message1 - verification code sent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If4": {
      "main": [
        [
          {
            "node": "If2- extracted_email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Greeting 1 - send your email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook": {
      "main": [
        [
          {
            "node": "Universal normalization",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save OTP": {
      "main": [
        [
          {
            "node": "Send email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "valid OTP": {
      "main": [
        [
          {
            "node": "Execute a SQL query2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send email": {
      "main": [
        [
          {
            "node": "waiting_for_otp",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate OTP": {
      "main": [
        [
          {
            "node": "Save OTP",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract email": {
      "main": [
        [
          {
            "node": "If4",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF_resend_otp": {
      "main": [
        [],
        [
          {
            "node": "Execute a SQL query1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF_code_exists?": {
      "main": [
        [
          {
            "node": "valid OTP",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "code does not exist",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "waiting_for_otp": {
      "main": [
        [
          {
            "node": "If2",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Execute a SQL query": {
      "main": [
        [
          {
            "node": "Search onboarding_session",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If email en session": {
      "main": [
        [
          {
            "node": "Upsert user by email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Missing email response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert user_channel": {
      "main": [
        [
          {
            "node": "Verification completed response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Execute a SQL query1": {
      "main": [
        [
          {
            "node": "IF_code_exists?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Execute a SQL query2": {
      "main": [
        [
          {
            "node": "Get verified session email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Execute a SQL query3": {
      "main": [
        [
          {
            "node": "Channel already verified",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Execute a SQL query5": {
      "main": [
        [
          {
            "node": "Generate OTP",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF_step- waiting_otp": {
      "main": [
        [
          {
            "node": "IF_resend_otp",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If2- extracted_email": {
      "main": [
        [
          {
            "node": "Execute a SQL query5",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert user by email": {
      "main": [
        [
          {
            "node": "Upsert user_channel",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Universal normalization": {
      "main": [
        [
          {
            "node": "Check if the channel already exists",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Search onboarding_session": {
      "main": [
        [
          {
            "node": "If1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get verified session email": {
      "main": [
        [
          {
            "node": "If email en session",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check if the channel already exists": {
      "main": [
        [
          {
            "node": "If",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

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

About this workflow

How it works

Source: https://n8n.io/workflows/14209/ — original creator credit. Request a take-down →

More Data & Sheets workflows → · Browse all categories →

Related workflows

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

Data & Sheets

This workflow automates data maturity evaluation to measure how well an organization uses data to create value by capturing assessment data through forms or APIs, processing and scoring responses usin

Email Send, Postgres
Data & Sheets

This n8n workflow automates the transformation of raw text ideas into structured visual diagrams and content assets using NapkinAI.

HTTP Request, Email Send, Postgres
Data & Sheets

Receive request via webhook with customer question Analyze sentiment and detect urgency using JavaScript Send urgent alerts to Slack for critical cases Search knowledge base and fetch conversation his

HTTP Request, Postgres, Email Send +1
Data & Sheets

PURPOSE: Automatically send professional appointment reminders via email and SMS to reduce no-shows and improve patient experience.

Google Sheets, Airtable, Email Send +2
Data & Sheets

Enviador de Feedback - Adeptify.es. Uses emailSend, postgres. Webhook trigger; 7 nodes.

Email Send, Postgres