AutomationFlowsMarketing & Ads › Workflow a — Whatsapp Lead Intake & Qualification

Workflow a — Whatsapp Lead Intake & Qualification

Workflow A — WhatsApp Lead Intake & Qualification. Uses postgres, httpRequest, errorTrigger. Scheduled trigger; 67 nodes.

Cron / scheduled trigger★★★★★ complexity67 nodesPostgresHTTP RequestError Trigger
Marketing & Ads Trigger: Cron / scheduled Nodes: 67 Complexity: ★★★★★ Added:

This workflow follows the Error Trigger → HTTP Request 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
{
  "name": "Workflow A \u2014 WhatsApp Lead Intake & Qualification",
  "nodes": [
    {
      "id": "wfa00001-0001-0001-0001-000000000001",
      "name": "Cron \u2014 Every 1 Min",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        200,
        300
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "* * * * *"
            }
          ]
        }
      }
    },
    {
      "id": "wfa00002-0002-0002-0002-000000000002",
      "name": "Claim Inbox Batch",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        440,
        300
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE lead_inbox SET status='CLAIMED', claimed_at=NOW() WHERE id IN (SELECT id FROM lead_inbox WHERE status='NEW' ORDER BY received_at LIMIT 5 FOR UPDATE SKIP LOCKED) RETURNING *;",
        "options": {}
      }
    },
    {
      "id": "wfa00003-0003-0003-0003-000000000003",
      "name": "Has Claimed Rows?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        680,
        300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-has-rows",
              "leftValue": "={{ $json.id ?? '' }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              }
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "wfa00004-0004-0004-0004-000000000004",
      "name": "Process Each Row",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        920,
        200
      ],
      "parameters": {
        "batchSize": 1,
        "options": {}
      }
    },
    {
      "id": "wfa00005-0005-0005-0005-000000000005",
      "name": "Bake Row Context",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1160,
        200
      ],
      "parameters": {
        "mode": "manual",
        "duplicateItem": false,
        "assignments": {
          "assignments": [
            {
              "id": "bake-inbox-id",
              "name": "inboxId",
              "value": "={{ $json.id }}",
              "type": "string"
            },
            {
              "id": "bake-phone",
              "name": "phoneE164",
              "value": "={{ $json.phone_e164 }}",
              "type": "string"
            },
            {
              "id": "bake-body",
              "name": "messageBody",
              "value": "={{ $json.body ?? '' }}",
              "type": "string"
            },
            {
              "id": "bake-wa-msg-id",
              "name": "waMsgId",
              "value": "={{ $json.wa_message_id }}",
              "type": "string"
            },
            {
              "id": "bake-lead-id",
              "name": "inboxLeadId",
              "value": "={{ $json.lead_id ?? '' }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "wfa00006-0006-0006-0006-000000000006",
      "name": "Resolve Lead by Phone",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "onError": "continueRegularOutput",
      "position": [
        1400,
        200
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $env.TWENTY_API_URL + '/graphql' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + $env.TWENTY_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "contentType": "json",
        "jsonBody": "={{ { query: '{ leads(filter: { phoneE164: { primaryPhoneNumber: { eq: \"' + $json.phoneE164 + '\" } } }) { edges { node { id name consentStatus qualifiedAt assignedAgent { id } } } } }' } }}",
        "options": {
          "timeout": 15000,
          "retry": {
            "enabled": true,
            "maxTries": 2,
            "waitBetweenTries": 2000
          }
        }
      }
    },
    {
      "id": "wfa00007-0007-0007-0007-000000000007",
      "name": "GQL Errors \u2014 Resolve Lead?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1640,
        200
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-gql-errors",
              "leftValue": "={{ ($json.errors?.length ?? 0) }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "wfa00008-0008-0008-0008-000000000008",
      "name": "Log GQL Resolve Error",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        1880,
        80
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO workflow_errors (workflow_name, execution_id, node_name, error_message, error_stack) VALUES ('workflow-a-lead-intake', $1, 'Resolve Lead by Phone', $2, $3)",
        "options": {
          "queryReplacement": "={{ [$execution.id, ('GQL error resolving lead: ' + ($json.errors?.[0]?.message ?? 'unknown')), ($json.errors?.[0]?.extensions?.code ?? 'GQL_ERROR')] }}"
        }
      }
    },
    {
      "id": "wfa00009-0009-0009-0009-000000000009",
      "name": "Mark Inbox Failed \u2014 GQL Error",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        2120,
        80
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE lead_inbox SET status='FAILED', error_message=$1 WHERE id=$2::bigint",
        "options": {
          "queryReplacement": "={{ ['GQL error on Resolve Lead by Phone', $('Bake Row Context').first()?.json?.inboxId] }}"
        }
      }
    },
    {
      "id": "wfa00010-0010-0010-0010-000000000010",
      "name": "Lead Exists?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1880,
        320
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-lead-exists",
              "leftValue": "={{ $json.data?.leads?.edges?.[0]?.node?.id ?? '' }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              }
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "wfa00011-0011-0011-0011-000000000011",
      "name": "Create Lead in Twenty",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "onError": "continueRegularOutput",
      "position": [
        2120,
        460
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $env.TWENTY_API_URL + '/graphql' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + $env.TWENTY_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "contentType": "json",
        "jsonBody": "={{ { query: 'mutation { createLead(data: { name: { firstName: \"Lead\", lastName: \"' + $('Bake Row Context').first()?.json?.phoneE164 + '\" }, phoneE164: { primaryPhoneNumber: \"' + $('Bake Row Context').first()?.json?.phoneE164 + '\", primaryPhoneCountryCode: \"GH\" }, consentStatus: \"PENDING\" }) { id name consentStatus } }' } }}",
        "options": {
          "timeout": 15000,
          "retry": {
            "enabled": true,
            "maxTries": 2,
            "waitBetweenTries": 2000
          }
        }
      }
    },
    {
      "id": "wfa00012-0012-0012-0012-000000000012",
      "name": "GQL Errors \u2014 Create Lead?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2360,
        460
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-create-gql-errors",
              "leftValue": "={{ ($json.errors?.length ?? 0) }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "wfa00013-0013-0013-0013-000000000013",
      "name": "Log Create Lead GQL Error",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        2600,
        360
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO workflow_errors (workflow_name, execution_id, node_name, error_message, error_stack) VALUES ('workflow-a-lead-intake', $1, 'Create Lead in Twenty', $2, $3)",
        "options": {
          "queryReplacement": "={{ [$execution.id, ('GQL error creating lead: ' + ($json.errors?.[0]?.message ?? 'unknown')), ($json.errors?.[0]?.extensions?.code ?? 'GQL_ERROR')] }}"
        }
      }
    },
    {
      "id": "wfa00014-0014-0014-0014-000000000014",
      "name": "Mark Inbox Failed \u2014 Create Lead",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        2840,
        360
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE lead_inbox SET status='FAILED', error_message=$1 WHERE id=$2::bigint",
        "options": {
          "queryReplacement": "={{ ['GQL error creating lead', $('Bake Row Context').first()?.json?.inboxId] }}"
        }
      }
    },
    {
      "id": "wfa00015-0015-0015-0015-000000000015",
      "name": "Insert lead_facts \u2014 New Lead",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        2600,
        560
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO lead_facts (lead_id, phone_e164, consent_status, last_inbound_at, updated_at) VALUES ($1, $2, 'PENDING', NOW(), NOW()) ON CONFLICT (lead_id) DO NOTHING",
        "options": {
          "queryReplacement": "={{ [$json.data?.createLead?.id, $('Bake Row Context').first()?.json?.phoneE164] }}"
        }
      }
    },
    {
      "id": "wfa00016-0016-0016-0016-000000000016",
      "name": "Get or Create Conversation \u2014 New Lead",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        2840,
        560
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO conversation (lead_id, kind) VALUES ($1, 'LEAD_QUALIFICATION') ON CONFLICT DO NOTHING; SELECT id FROM conversation WHERE lead_id=$1 AND kind='LEAD_QUALIFICATION' AND closed_at IS NULL ORDER BY started_at DESC LIMIT 1",
        "options": {
          "queryReplacement": "={{ [$('Insert lead_facts \u2014 New Lead').first()?.json?.lead_id ?? $('Create Lead in Twenty').first()?.json?.data?.createLead?.id] }}"
        }
      }
    },
    {
      "id": "wfa00017-0017-0017-0017-000000000017",
      "name": "Store Inbound Message \u2014 New Lead",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        3080,
        560
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO conversation_message (conversation_id, direction, wa_message_id, body, created_at) VALUES ($1::uuid, 'IN', $2, $3, NOW()) ON CONFLICT (wa_message_id) DO NOTHING",
        "options": {
          "queryReplacement": "={{ [$json.id, $('Bake Row Context').first()?.json?.waMsgId, $('Bake Row Context').first()?.json?.messageBody] }}"
        }
      }
    },
    {
      "id": "wfa00018-0018-0018-0018-000000000018",
      "name": "Send Consent Request",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1.3,
      "position": [
        3320,
        560
      ],
      "parameters": {
        "workflowId": {
          "value": "SUBFLOW_WA_SEND_ID",
          "__rl": true,
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "to": "={{ $('Bake Row Context').first()?.json?.phoneE164 }}",
            "templateName": "consent_request",
            "templateVars": "={{ ['Lead'] }}",
            "conversationId": "={{ $('Get or Create Conversation \u2014 New Lead').first()?.json?.id ?? '' }}"
          },
          "schema": [
            {
              "id": "to",
              "displayName": "to",
              "type": "string",
              "required": true
            },
            {
              "id": "templateName",
              "displayName": "templateName",
              "type": "string",
              "required": false
            },
            {
              "id": "templateVars",
              "displayName": "templateVars",
              "type": "array",
              "required": false
            },
            {
              "id": "conversationId",
              "displayName": "conversationId",
              "type": "string",
              "required": false
            }
          ]
        }
      }
    },
    {
      "id": "wfa00019-0019-0019-0019-000000000019",
      "name": "Store Outbound Consent Request",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        3560,
        560
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO conversation_message (conversation_id, direction, body, template_name, created_at) VALUES ($1::uuid, 'OUT', $2, 'consent_request', NOW())",
        "options": {
          "queryReplacement": "={{ [$('Get or Create Conversation \u2014 New Lead').first()?.json?.id, 'Consent request template sent'] }}"
        }
      }
    },
    {
      "id": "wfa00020-0020-0020-0020-000000000020",
      "name": "Mark Inbox Processed \u2014 New Lead",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        3800,
        560
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE lead_inbox SET status='PROCESSED', processed_at=NOW() WHERE id=$1::bigint",
        "options": {
          "queryReplacement": "={{ [$('Bake Row Context').first()?.json?.inboxId] }}"
        }
      }
    },
    {
      "id": "wfa00021-0021-0021-0021-000000000021",
      "name": "Read lead_facts \u2014 Existing Lead",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        2120,
        200
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT lf.*, c.id AS conversation_id FROM lead_facts lf LEFT JOIN conversation c ON c.lead_id = lf.lead_id AND c.kind = 'LEAD_QUALIFICATION' AND c.closed_at IS NULL WHERE lf.phone_e164 = $1 ORDER BY c.started_at DESC LIMIT 1",
        "options": {
          "queryReplacement": "={{ [$('Bake Row Context').first()?.json?.phoneE164] }}"
        }
      }
    },
    {
      "id": "wfa00022-0022-0022-0022-000000000022",
      "name": "lead_facts Found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2360,
        200
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-facts-found",
              "leftValue": "={{ $json.lead_id ?? '' }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              }
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "wfa00023-0023-0023-0023-000000000023",
      "name": "Upsert lead_facts from Twenty",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        2600,
        80
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO lead_facts (lead_id, phone_e164, consent_status, last_inbound_at, updated_at) VALUES ($1, $2, $3, NOW(), NOW()) ON CONFLICT (lead_id) DO UPDATE SET consent_status=EXCLUDED.consent_status, last_inbound_at=NOW(), updated_at=NOW(); SELECT lf.*, c.id AS conversation_id FROM lead_facts lf LEFT JOIN conversation c ON c.lead_id=lf.lead_id AND c.kind='LEAD_QUALIFICATION' AND c.closed_at IS NULL WHERE lf.lead_id=$1 ORDER BY c.started_at DESC LIMIT 1",
        "options": {
          "queryReplacement": "={{ [$('Resolve Lead by Phone').first()?.json?.data?.leads?.edges?.[0]?.node?.id, $('Bake Row Context').first()?.json?.phoneE164, ($('Resolve Lead by Phone').first()?.json?.data?.leads?.edges?.[0]?.node?.consentStatus ?? 'PENDING')] }}"
        }
      }
    },
    {
      "id": "wfa00024-0024-0024-0024-000000000024",
      "name": "Get or Create Conversation \u2014 Existing Lead",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        2840,
        200
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO conversation (lead_id, kind) VALUES ($1, 'LEAD_QUALIFICATION') ON CONFLICT DO NOTHING; SELECT id FROM conversation WHERE lead_id=$1 AND kind='LEAD_QUALIFICATION' AND closed_at IS NULL ORDER BY started_at DESC LIMIT 1",
        "options": {
          "queryReplacement": "={{ [$json.lead_id] }}"
        }
      }
    },
    {
      "id": "wfa00025-0025-0025-0025-000000000025",
      "name": "Store Inbound Message \u2014 Existing Lead",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        3080,
        200
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO conversation_message (conversation_id, direction, wa_message_id, body, created_at) VALUES ($1::uuid, 'IN', $2, $3, NOW()) ON CONFLICT (wa_message_id) DO NOTHING",
        "options": {
          "queryReplacement": "={{ [$json.id, $('Bake Row Context').first()?.json?.waMsgId, $('Bake Row Context').first()?.json?.messageBody] }}"
        }
      }
    },
    {
      "id": "wfa00026-0026-0026-0026-000000000026",
      "name": "Bake Consent and Conversation",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        3320,
        200
      ],
      "parameters": {
        "mode": "manual",
        "duplicateItem": false,
        "assignments": {
          "assignments": [
            {
              "id": "bake-consent",
              "name": "consentStatus",
              "value": "={{ $('Upsert lead_facts from Twenty').first()?.json?.consent_status ?? $('Read lead_facts \u2014 Existing Lead').first()?.json?.consent_status ?? 'PENDING' }}",
              "type": "string"
            },
            {
              "id": "bake-conv-id",
              "name": "conversationId",
              "value": "={{ $('Get or Create Conversation \u2014 Existing Lead').first()?.json?.id ?? '' }}",
              "type": "string"
            },
            {
              "id": "bake-lead-id2",
              "name": "leadId",
              "value": "={{ $('Upsert lead_facts from Twenty').first()?.json?.lead_id ?? $('Read lead_facts \u2014 Existing Lead').first()?.json?.lead_id ?? '' }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "wfa00027-0027-0027-0027-000000000027",
      "name": "Switch on Consent Status",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        3560,
        200
      ],
      "parameters": {
        "mode": "rules",
        "rules": {
          "rules": [
            {
              "outputIndex": 0,
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "cond-pending",
                    "leftValue": "={{ $json.consentStatus }}",
                    "rightValue": "PENDING",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ]
              }
            },
            {
              "outputIndex": 1,
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "cond-granted",
                    "leftValue": "={{ $json.consentStatus }}",
                    "rightValue": "GRANTED",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ]
              }
            },
            {
              "outputIndex": 2,
              "conditions": {
                "options": {
                  "caseSensitive": false,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "cond-declined",
                    "leftValue": "={{ $json.consentStatus }}",
                    "rightValue": "REFUSED",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ]
              }
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "wfa00028-0028-0028-0028-000000000028",
      "name": "Parse Consent Reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3800,
        80
      ],
      "parameters": {
        "language": "javaScript",
        "jsCode": "const body = ($('Bake Row Context').first()?.json?.messageBody ?? '').toLowerCase().trim();\nconst yesWords = ['yes','y','agree','ok','consent','yep','yeah','sure','accept'];\nconst noWords = ['no','n','stop','decline','refuse','nope','nah','cancel'];\nconst isYes = yesWords.some(w => body === w || body.startsWith(w + ' '));\nconst isNo  = noWords.some(w => body === w || body.startsWith(w + ' '));\nreturn [{ json: { consentReply: isYes ? 'YES' : isNo ? 'NO' : 'UNCLEAR', leadId: $('Bake Row Context').first()?.json?.inboxLeadId || '', phoneE164: $('Bake Row Context').first()?.json?.phoneE164, conversationId: $('Bake Consent and Conversation').first()?.json?.conversationId } }];"
      }
    },
    {
      "id": "wfa00029-0029-0029-0029-000000000029",
      "name": "Consent Reply \u2014 YES or NO?",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3,
      "position": [
        4040,
        80
      ],
      "parameters": {
        "mode": "rules",
        "rules": {
          "rules": [
            {
              "outputIndex": 0,
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "cond-yes",
                    "leftValue": "={{ $json.consentReply }}",
                    "rightValue": "YES",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ]
              }
            },
            {
              "outputIndex": 1,
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "loose"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "cond-no",
                    "leftValue": "={{ $json.consentReply }}",
                    "rightValue": "NO",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ]
              }
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "wfa00030-0030-0030-0030-000000000030",
      "name": "Update Lead Consent GRANTED",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "onError": "continueRegularOutput",
      "position": [
        4280,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $env.TWENTY_API_URL + '/graphql' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + $env.TWENTY_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "contentType": "json",
        "jsonBody": "={{ { query: 'mutation { updateLead(id: \"' + $('Bake Consent and Conversation').first()?.json?.leadId + '\", data: { consentStatus: \"GRANTED\" }) { id consentStatus } }' } }}",
        "options": {
          "timeout": 15000,
          "retry": {
            "enabled": true,
            "maxTries": 2,
            "waitBetweenTries": 2000
          }
        }
      }
    },
    {
      "id": "wfa00031-0031-0031-0031-000000000031",
      "name": "Update lead_facts Consent GRANTED",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        4520,
        0
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE lead_facts SET consent_status='GRANTED', updated_at=NOW() WHERE lead_id=$1",
        "options": {
          "queryReplacement": "={{ [$('Bake Consent and Conversation').first()?.json?.leadId] }}"
        }
      }
    },
    {
      "id": "wfa00032-0032-0032-0032-000000000032",
      "name": "Send Qualification Opener",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1.3,
      "position": [
        4760,
        0
      ],
      "parameters": {
        "workflowId": {
          "value": "SUBFLOW_WA_SEND_ID",
          "__rl": true,
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "to": "={{ $('Bake Row Context').first()?.json?.phoneE164 }}",
            "body": "Great, thanks for agreeing! I'm here to help you find your ideal property in Accra or Ashanti. Could you tell me what you're looking for \u2014 are you buying or renting?",
            "conversationId": "={{ $('Bake Consent and Conversation').first()?.json?.conversationId }}"
          },
          "schema": [
            {
              "id": "to",
              "displayName": "to",
              "type": "string",
              "required": true
            },
            {
              "id": "body",
              "displayName": "body",
              "type": "string",
              "required": false
            },
            {
              "id": "conversationId",
              "displayName": "conversationId",
              "type": "string",
              "required": false
            }
          ]
        }
      }
    },
    {
      "id": "wfa00033-0033-0033-0033-000000000033",
      "name": "Store Outbound Opener",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        5000,
        0
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO conversation_message (conversation_id, direction, body, created_at) VALUES ($1::uuid, 'OUT', $2, NOW())",
        "options": {
          "queryReplacement": "={{ [$('Bake Consent and Conversation').first()?.json?.conversationId, 'Great, thanks for agreeing! Qualification opener sent.'] }}"
        }
      }
    },
    {
      "id": "wfa00034-0034-0034-0034-000000000034",
      "name": "Mark Inbox Processed \u2014 Consent YES",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        5240,
        0
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE lead_inbox SET status='PROCESSED', processed_at=NOW() WHERE id=$1::bigint",
        "options": {
          "queryReplacement": "={{ [$('Bake Row Context').first()?.json?.inboxId] }}"
        }
      }
    },
    {
      "id": "wfa00035-0035-0035-0035-000000000035",
      "name": "Update Lead Consent DECLINED",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "onError": "continueRegularOutput",
      "position": [
        4280,
        160
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $env.TWENTY_API_URL + '/graphql' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + $env.TWENTY_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "contentType": "json",
        "jsonBody": "={{ { query: 'mutation { updateLead(id: \"' + $('Bake Consent and Conversation').first()?.json?.leadId + '\", data: { consentStatus: \"REFUSED\" }) { id consentStatus } }' } }}",
        "options": {
          "timeout": 15000,
          "retry": {
            "enabled": true,
            "maxTries": 2,
            "waitBetweenTries": 2000
          }
        }
      }
    },
    {
      "id": "wfa00036-0036-0036-0036-000000000036",
      "name": "Update lead_facts Consent DECLINED",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        4520,
        160
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE lead_facts SET consent_status='REFUSED', do_not_contact=true, updated_at=NOW() WHERE lead_id=$1",
        "options": {
          "queryReplacement": "={{ [$('Bake Consent and Conversation').first()?.json?.leadId] }}"
        }
      }
    },
    {
      "id": "wfa00037-0037-0037-0037-000000000037",
      "name": "Send Decline Acknowledgement",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1.3,
      "position": [
        4760,
        160
      ],
      "parameters": {
        "workflowId": {
          "value": "SUBFLOW_WA_SEND_ID",
          "__rl": true,
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "to": "={{ $('Bake Row Context').first()?.json?.phoneE164 }}",
            "body": "No problem at all. We won't contact you further. If you change your mind, just message us again. Take care!",
            "conversationId": "={{ $('Bake Consent and Conversation').first()?.json?.conversationId }}"
          },
          "schema": [
            {
              "id": "to",
              "displayName": "to",
              "type": "string",
              "required": true
            },
            {
              "id": "body",
              "displayName": "body",
              "type": "string",
              "required": false
            },
            {
              "id": "conversationId",
              "displayName": "conversationId",
              "type": "string",
              "required": false
            }
          ]
        }
      }
    },
    {
      "id": "wfa00038-0038-0038-0038-000000000038",
      "name": "Mark Inbox Processed \u2014 Consent NO",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        5000,
        160
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE lead_inbox SET status='PROCESSED', processed_at=NOW() WHERE id=$1::bigint",
        "options": {
          "queryReplacement": "={{ [$('Bake Row Context').first()?.json?.inboxId] }}"
        }
      }
    },
    {
      "id": "wfa00039-0039-0039-0039-000000000039",
      "name": "Mark Inbox Processed \u2014 Consent UNCLEAR",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        4280,
        300
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE lead_inbox SET status='PROCESSED', processed_at=NOW() WHERE id=$1::bigint",
        "options": {
          "queryReplacement": "={{ [$('Bake Row Context').first()?.json?.inboxId] }}"
        }
      }
    },
    {
      "id": "wfa00040-0040-0040-0040-000000000040",
      "name": "Fetch Conversation History",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        3800,
        200
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT direction, body, created_at FROM conversation_message WHERE conversation_id=$1::uuid ORDER BY created_at DESC LIMIT 30",
        "options": {
          "queryReplacement": "={{ [$('Bake Consent and Conversation').first()?.json?.conversationId] }}"
        }
      }
    },
    {
      "id": "wfa00041-0041-0041-0041-000000000041",
      "name": "Build Messages Array",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4040,
        200
      ],
      "parameters": {
        "language": "javaScript",
        "jsCode": "const rows = $input.all();\nconst sorted = rows.slice().sort((a, b) => new Date(a.json.created_at) - new Date(b.json.created_at));\nconst messages = sorted.map(r => ({ role: r.json.direction === 'IN' ? 'user' : 'assistant', content: r.json.body ?? '' }));\nconst leadId = $('Bake Consent and Conversation').first()?.json?.leadId ?? '';\nconst conversationId = $('Bake Consent and Conversation').first()?.json?.conversationId ?? '';\nconst phoneE164 = $('Bake Row Context').first()?.json?.phoneE164 ?? '';\nconst inboxId = $('Bake Row Context').first()?.json?.inboxId ?? '';\nconst waMsgId = $('Bake Row Context').first()?.json?.waMsgId ?? '';\nconst messageBody = $('Bake Row Context').first()?.json?.messageBody ?? '';\nreturn [{ json: { messages, leadId, conversationId, phoneE164, inboxId, waMsgId, messageBody } }];"
      }
    },
    {
      "id": "wfa00042-0042-0042-0042-000000000042",
      "name": "Generate Reply \u2014 gpt-4.1-mini",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1.3,
      "position": [
        4280,
        200
      ],
      "parameters": {
        "workflowId": {
          "value": "SUBFLOW_OPENAI_CALL_ID",
          "__rl": true,
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "system": "You are a friendly, professional real-estate assistant for a Ghanaian agency. Your job is to qualify leads by gathering: intent (BUY or RENT), budget range in GHS, minimum bedrooms, property type(s), target areas in Greater Accra or Ashanti, desired move-in timeline in months, and financing status. Ask one question at a time. Use plain Ghanaian English. Be warm but concise. Never mention you are an AI unless directly asked.",
            "messages": "={{ $json.messages }}",
            "model": "gpt-4.1-mini",
            "max_tokens": 300,
            "workflow_label": "workflow-a-reply"
          },
          "schema": [
            {
              "id": "system",
              "displayName": "system",
              "type": "string",
              "required": true
            },
            {
              "id": "messages",
              "displayName": "messages",
              "type": "array",
              "required": true
            },
            {
              "id": "model",
              "displayName": "model",
              "type": "string",
              "required": false
            },
            {
              "id": "max_tokens",
              "displayName": "max_tokens",
              "type": "number",
              "required": false
            },
            {
              "id": "workflow_label",
              "displayName": "workflow_label",
              "type": "string",
              "required": false
            }
          ]
        }
      }
    },
    {
      "id": "wfa00043-0043-0043-0043-000000000043",
      "name": "Extract Qualification Facts \u2014 gpt-4.1-mini",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1.3,
      "position": [
        4520,
        200
      ],
      "parameters": {
        "workflowId": {
          "value": "SUBFLOW_OPENAI_CALL_ID",
          "__rl": true,
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "system": "You are a data-extraction assistant. From the conversation history, extract the lead qualification facts as a JSON object. Return ONLY a valid JSON object with these exact keys: intent (BUY or RENT or null), budgetMinGhs (integer or null), budgetMaxGhs (integer or null), bedroomsMin (integer 1-6 or null), propertyType (array of strings from: APARTMENT, HOUSE, TOWNHOUSE, LAND, COMMERCIAL, SELF_CONTAIN, CHAMBER_AND_HALL, BOYS_QUARTERS; or empty array), targetAreas (array of area names or empty array), timelineMonths (integer 0-12 or null), financingStatus (CASH or MORTGAGE_PRE_APPROVED or MORTGAGE_PENDING or UNKNOWN), confidence (float 0.0-1.0 representing overall extraction confidence). No explanation, no markdown, just the JSON object.",
            "messages": "={{ $('Build Messages Array').first()?.json?.messages }}",
            "model": "gpt-4.1-mini",
            "max_tokens": 400,
            "response_format": "json_object",
            "workflow_label": "workflow-a-extract-facts"
          },
          "schema": [
            {
              "id": "system",
              "displayName": "system",
              "type": "string",
              "required": true
            },
            {
              "id": "messages",
              "displayName": "messages",
              "type": "array",
              "required": true
            },
            {
              "id": "model",
              "displayName": "model",
              "type": "string",
              "required": false
            },
            {
              "id": "max_tokens",
              "displayName": "max_tokens",
              "type": "number",
              "required": false
            },
            {
              "id": "response_format",
              "displayName": "response_format",
              "type": "string",
              "required": false
            },
            {
              "id": "workflow_label",
              "displayName": "workflow_label",
              "type": "string",
              "required": false
            }
          ]
        }
      }
    },
    {
      "id": "wfa00044-0044-0044-0044-000000000044",
      "name": "Parse Facts JSON",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4760,
        200
      ],
      "parameters": {
        "language": "javaScript",
        "jsCode": "const rawText = $json.text ?? '{}';\nlet facts = {};\ntry { facts = JSON.parse(rawText); } catch(e) { facts = {}; }\nconst intent = facts.intent ?? null;\nconst budgetMinGhs = facts.budgetMinGhs ?? null;\nconst budgetMaxGhs = facts.budgetMaxGhs ?? null;\nconst bedroomsMin = facts.bedroomsMin ?? null;\nconst propertyType = Array.isArray(facts.propertyType) ? facts.propertyType : [];\nconst targetAreas = Array.isArray(facts.targetAreas) ? facts.targetAreas : [];\nconst timelineMonths = facts.timelineMonths ?? null;\nconst financingStatus = facts.financingStatus ?? 'UNKNOWN';\nconst confidence = typeof facts.confidence === 'number' ? facts.confidence : 0;\nconst allRequired = intent !== null && budgetMinGhs !== null && budgetMaxGhs !== null && bedroomsMin !== null && propertyType.length > 0;\nconst qualified = allRequired && confidence >= 0.8;\nconst replyText = $('Generate Reply \u2014 gpt-4.1-mini').first()?.json?.text ?? '';\nconst leadId = $('Build Messages Array').first()?.json?.leadId ?? '';\nconst conversationId = $('Build Messages Array').first()?.json?.conversationId ?? '';\nconst phoneE164 = $('Build Messages Array').first()?.json?.phoneE164 ?? '';\nconst inboxId = $('Build Messages Array').first()?.json?.inboxId ?? '';\nconst waMsgId = $('Build Messages Array').first()?.json?.waMsgId ?? '';\nreturn [{ json: { intent, budgetMinGhs, budgetMaxGhs, bedroomsMin, propertyType, targetAreas, timelineMonths, financingStatus, confidence, qualified, allRequired, replyText, leadId, conversationId, phoneE164, inboxId, waMsgId } }];"
      }
    },
    {
      "id": "wfa00045-0045-0045-0045-000000000045",
      "name": "Qualification Complete?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        5000,
        200
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-qualified",
              "leftValue": "={{ $json.qualified }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true"
              }
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "wfa00046-0046-0046-0046-000000000046",
      "name": "Update Lead Qualified Fields",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "onError": "continueRegularOutput",
      "position": [
        5240,
        80
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $env.TWENTY_API_URL + '/graphql' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + $env.TWENTY_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "contentType": "json",
        "jsonBody": "={{ (function() { const f = $json; const score = Math.round((f.confidence ?? 0) * 100); const ptArr = (f.propertyType || []).map(function(v) { return '\"' + v + '\"'; }).join(','); const taArr = (f.targetAreas || []).map(function(v) { return '\"' + v + '\"'; }).join(','); return { query: 'mutation { updateLead(id: \"' + f.leadId + '\", data: { qualifiedAt: \"' + new Date().toISOString() + '\", qualifiedScore: ' + score + (f.intent ? ', intent: \"' + f.intent + '\"' : '') + (f.budgetMinGhs !== null ? ', budgetMinGhs: ' + f.budgetMinGhs : '') + (f.budgetMaxGhs !== null ? ', budgetMaxGhs: ' + f.budgetMaxGhs : '') + (f.bedroomsMin !== null ? ', bedroomsMin: ' + f.bedroomsMin : '') + (ptArr ? ', propertyType: [' + ptArr + ']' : '') + (taArr ? ', targetAreas: [' + taArr + ']' : '') + (f.timelineMonths !== null ? ', timelineMonths: ' + f.timelineMonths : '') + (f.financingStatus ? ', financingStatus: \"' + f.financingStatus + '\"' : '') + ' }) { id qualifiedAt } }' }; })() }}",
        "options": {
          "timeout": 15000,
          "retry": {
            "enabled": true,
            "maxTries": 2,
            "waitBetweenTries": 2000
          }
        }
      }
    },
    {
      "id": "wfa00047-0047-0047-0047-000000000047",
      "name": "Assign Agent",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        5480,
        80
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT assign_agent_for_property_type($1) AS agent_id",
        "options": {
          "queryReplacement": "={{ [$('Parse Facts JSON').first()?.json?.propertyType?.[0] ?? 'APARTMENT'] }}"
        }
      }
    },
    {
      "id": "wfa00048-0048-0048-0048-000000000048",
      "name": "Agent Found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        5720,
        80
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-agent-found",
              "leftValue": "={{ $json.agent_id ?? '' }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              }
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "wfa00049-0049-0049-0049-000000000049",
      "name": "Update Lead Assigned Agent",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "onError": "continueRegularOutput",
      "position": [
        5960,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $env.TWENTY_API_URL + '/graphql' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + $env.TWENTY_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "contentType": "json",
        "jsonBody": "={{ { query: 'mutation { updateLead(id: \"' + $('Parse Facts JSON').first()?.json?.leadId + '\", data: { assignedAgentId: \"' + $json.agent_id + '\" }) { id assignedAgent { id } } }' } }}",
        "options": {
          "timeout": 15000,
          "retry": {
            "enabled": true,
            "maxTries": 2,
            "waitBetweenTries": 2000
          }
        }
      }
    },
    {
      "id": "wfa00050-0050-0050-0050-000000000050",
      "name": "Update lead_facts Assigned Agent",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        6200,
        0
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE lead_facts SET assigned_agent_id=$1, qualified_at=NOW(), updated_at=NOW() WHERE lead_id=$2",
        "options": {
          "queryReplacement": "={{ [$('Assign Agent').first()?.json?.agent_id, $('Parse Facts JSON').first()?.json?.leadId] }}"
        }
      }
    },
    {
      "id": "wfa00051-0051-0051-0051-000000000051",
      "name": "Create ReviewTask \u2014 AGENT_POOL_EMPTY",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "onError": "continueRegularOutput",
      "position": [
        5960,
        160
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $env.TWENTY_API_URL + '/graphql' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + $env.TWENTY_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "contentType": "json",
        "jsonBody": "={{ { query: 'mutation { createReviewTask(data: { kind: \"AGENT_POOL_EMPTY\", subjectLead: { connect: { where: { id: \"' + ($('Parse Facts JSON').first()?.json?.leadId ?? '') + '\" } } } }) { id kind } }' } }}",
        "options": {
          "timeout": 15000,
          "retry": {
            "enabled": true,
            "maxTries": 2,
            "waitBetweenTries": 2000
          }
        }
      }
    },
    {
      "id": "wfa00052-0052-0052-0052-000000000052",
      "name": "Calibration Window Active?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        6440,
        80
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": false,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-calibration",
              "leftValue": "={{ $env.CALIBRATION_WINDOW_ACTIVE ?? 'false' }}",
              "rightValue": "true",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "wfa00053-0053-0053-0053-000000000053",
      "name": "Create ReviewTask \u2014 LEAD_REPLY_REVIEW",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "onError": "continueRegularOutput",
      "position": [
        6680,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $env.TWENTY_API_URL + '/graphql' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + $env.TWENTY_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "contentType": "json",
        "jsonBody": "={{ { query: 'mutation { createReviewTask(data: { kind: \"LEAD_REPLY_REVIEW\", subjectLead: { connect: { where: { id: \"' + ($('Parse Facts JSON').first()?.json?.leadId ?? '') + '\" } } } }) { id kind } }' } }}",
        "options": {
          "timeout": 15000,
          "retry": {
            "enabled": true,
            "maxTries": 2,
            "waitBetweenTries": 2000
          }
        }
      }
    },
    {
      "id": "wfa00054-0054-0054-0054-000000000054",
      "name": "Send Qualified Reply",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1.3,
      "position": [
        6680,
        160
      ],
      "parameters": {
        "workflowId": {
          "value": "SUBFLOW_WA_SEND_ID",
          "__rl": true,
          "mode": "id"
        },
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {
            "to": "={{ $('Parse Facts JSON').first()?.json?.phoneE164 }}",
            "body": "={{ $('Parse Facts JSON').first()?.json?.replyText }}",
            "conversationId": "={{ $('Parse Facts JSON').first()?.json?.conversationId }}"
          },
          "schema": [
            {
              "id": "to",
              "displayName": "to",
              "type": "string",
              "required": true
            },
            {
              "id": "body",
              "displayName": "body",
              "type": "string",
              "required": false
            },
            {
              "id": "conversationId",
              "displayName": "conversationId",
              "type": "string",
              "required": false
            }
          ]
        }
      }
    },
    {
      "id": "wfa00055-0055-0055-0055-000000000055",
      "name": "Store Outbound Qualified Reply",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        6920,
        160
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO conversation_message (conversation_id, direction, body, created_at) VALUES ($1::uuid, 'OUT', $2, NOW())",
        "options": {
          "queryReplacement": "={{ [$('Parse Facts JSON').first()?.json?.conversationId, $('Parse Facts JSON').first()?.json?.replyText] }}"
        }
      }
    },
    {
      "id": "wfa00056-0056-0056-0056-000000000056",
      "name": "Mark Inbox Processed \u2014 Qualified",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        7160,
        160
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "UPDATE lead_inbox SET status='PROCESSED', processed_at=NOW() WHERE id=$1::bigint",
        "options": {
          "queryReplacement": "={{ [$('Parse Facts JSON').first()?.json?.inboxId] }}"
        }
      }
    },
    {
      "id": "wfa00057-0057-0057-0057-000000000057",
      "name": "Count Outbound Turns",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "position": [
        5240,
        320
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT COUNT(*) AS turn_count FROM conversation_message WHERE conversation_id=$1::uuid AND direction='OUT'",
        "options": {
          "queryReplacement": "={{ [$('Parse Facts JSON').first()?.json?.conversationId] }}"
        }
      }
    },
    {
      "id": "wfa00058-0058-0058-0058-000000000058",
      "name": "Turn Limit Reached?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        5480,
        320
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cond-turn-limit",
              "leftValue": "={{ Number($json.turn_count ?? 0) }}",
              "rightValue": 8,
              "operator": {
                "type": "number",
                "operation": "gte"
              }
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "wfa00059-0059-0059-0059-000000000059",
      "name": "Create ReviewTask \u2014 LEAD_QUALIFICATION_STUCK",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "onError": "continueRegularOutput",
      "position": [
        5720,
        240
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ $env.TWENTY_API_URL + '/graphql' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + $env.TWENTY_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "contentType": "json",
        "jsonBody": "={{ { query: 'mutation { createReviewTask(data: { kind: \"LEAD_QUALIFICATION_STUCK\", subjectLead: { connect: { where: { id: \"' + ($('Parse Facts JSON').first()?.j

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

Workflow A — WhatsApp Lead Intake & Qualification. Uses postgres, httpRequest, errorTrigger. Scheduled trigger; 67 nodes.

Source: https://github.com/jameszokah/real-estate-n8n-workflow/blob/ff6c75dac6b332a260a52efcb695ac4f3c962ed3/n8n-workflows/a-lead-intake.json — original creator credit. Request a take-down →

More Marketing & Ads workflows → · Browse all categories →

Related workflows

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

Marketing & Ads

This automation creates a seamless daily pipeline that: Pulls yesterday's website visitors from Leadfeeder Enriches company data using Apollo.io's powerful database Delivers enriched leads to your Goo

HTTP Request, Google Sheets, Error Trigger +1
Marketing & Ads

This workflow fetches unqualified leads from Postgres at defined retry intervals, sends personalized WhatsApp template messages via Gallabox API, and logs message activity while updating lead status i

Postgres, HTTP Request
Marketing & Ads

Build authentic Reddit presence and generate qualified leads through AI-powered community engagement that provides genuine value without spam or promotion.

HTTP Request, Reddit
Marketing & Ads

This workflow automates bulk email campaigns with built-in validation, deliverability protection, and smart send-time optimization.

HTTP Request, Postgres, Gmail
Marketing & Ads

This workflow runs on scheduled weekly and monthly triggers to generate unified marketing performance reports. It processes multiple websites by collecting analytics data, paid ads performance, and CR

Gmail, Google Sheets, Google Analytics +3