AutomationFlowsSlack & Telegram › Ahb — Part A: Fub → Slack Stage Notifications

Ahb — Part A: Fub → Slack Stage Notifications

AHB — Part A: FUB → Slack stage notifications. Uses httpRequest, slack. Webhook trigger; 10 nodes.

Webhook trigger★★★★☆ complexity10 nodesHTTP RequestSlack
Slack & Telegram Trigger: Webhook Nodes: 10 Complexity: ★★★★☆ Added:

This workflow corresponds to n8n.io template #ahb-fub-slack — we link there as the canonical source.

This workflow follows the HTTP Request → Slack 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": "ahbFubSlackPartA",
  "name": "AHB \u2014 Part A: FUB \u2192 Slack stage notifications",
  "nodes": [
    {
      "parameters": {
        "content": "## \ud83d\udce3 PART A \u2014 Follow Up Boss \u2192 Slack stage notifications\nWhen a lead is **created** or its **stage changes** in FUB, post a formatted message to the right Slack channel.\n\nTwo FUB webhooks (`peopleCreated`, `peopleStageUpdated`) point at the **one** Webhook URL below; the flow fetches the full record, decides the route, and posts. Channels are environment-aware (staging/prod) via `CHANNEL_ENV` in the shared config.",
        "height": 120,
        "width": 1140,
        "color": 7
      },
      "id": "node-0008-0000-4000-8000-000000000000",
      "name": "doc-overview",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        200,
        20
      ]
    },
    {
      "parameters": {
        "content": "### \u2460 Receive & fetch the lead\n**FUB Webhook** \u2014 receives FUB\u2019s `peopleCreated` / `peopleStageUpdated` events. Payload is *thin* (`event`, `resourceIds`, `uri`).\n\n**FUB Get Person** \u2014 GETs `/v1/people/{id}?fields=allFields` (HTTP Basic, API key = user). NOTE: `fields=allFields` is **required** or the *Lead Manager* custom field is missing. Retries 3\u00d7 on failure.",
        "height": 380,
        "width": 440,
        "color": 5
      },
      "id": "node-0009-0000-4000-8000-000000000000",
      "name": "doc-1-receive",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        200,
        160
      ]
    },
    {
      "parameters": {
        "content": "### \u2461 Decide the route\n**Route + Build Message** (Code) runs the shared routing logic:\n\u2022 stage \u2192 channel (Routes 1\u20139)\n\u2022 closer stages routed by `Assigned To` (Reyes / Flora / Marco)\n\u2022 closer stage with no `Assigned To` \u2192 catch-all to **#lead-managers**\n\u2022 no-notify / unknown stages \u2192 **skip**\n\nOutputs the target channel **ID** + the Block Kit message.",
        "height": 380,
        "width": 220,
        "color": 6
      },
      "id": "node-0010-0000-4000-8000-000000000000",
      "name": "doc-2-route",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        660,
        160
      ]
    },
    {
      "parameters": {
        "content": "### \u2462 Post to Slack (or skip)\n**Skip?** (IF) \u2014 if the router said `skip`, route to **No notification** (intentional dead-end; nothing posts).\n\nOtherwise \u2192 **Post to Slack** \u2014 posts the Block Kit message to the chosen channel **by ID** (bot token, retries 3\u00d7).",
        "height": 380,
        "width": 460,
        "color": 4
      },
      "id": "node-0011-0000-4000-8000-000000000000",
      "name": "doc-3-post",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        900,
        160
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "fub-events",
        "responseMode": "onReceived",
        "options": {}
      },
      "id": "node-0001-0000-4000-8000-000000000000",
      "name": "FUB Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        240,
        300
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "=https://api.followupboss.com/v1/people/{{ $json.body.resourceIds[0] }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpBasicAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "fields",
              "value": "allFields"
            }
          ]
        },
        "options": {}
      },
      "id": "node-0002-0000-4000-8000-000000000000",
      "name": "FUB Get Person",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        460,
        300
      ],
      "credentials": {
        "httpBasicAuth": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "language": "javaScript",
        "jsCode": "/**\n * config.js \u2014 SINGLE SOURCE OF TRUTH for the AHB FUB \u2192 Slack notification system.\n *\n * Both implementations (n8n and Make.com) are built FROM this file so they can\n * never drift apart. The tested logic in routing.js / phone.js / format.js\n * consumes these tables. The n8n Code nodes embed copies of the tested functions;\n * the Make build mirrors the same tables in its modules.\n *\n * Anything marked `CONFIRM:` is a reconciliation decision we made to unblock the\n * build \u2014 it MUST be verified with Marco / against the live FUB account before\n * acceptance. See docs/architecture.md \u00a7\"Ambiguities\".\n */\n\n// \u2500\u2500 FUB account-specific values Alexey must fill once (run shared/fub-scripts) \u2500\u2500\nconst FUB = {\n  // GET /v1/people/view link base. UNVERIFIED in FUB docs \u2014 confirm by opening a\n  // real record in the browser and copying the URL. (Research flagged this.)\n  PERSON_URL_BASE: 'https://app.followupboss.com/2/people/view/', // + personId\n\n  // The \"Lead Manager\" custom field. CONFIRMED 2026-06-14 via GET /v1/customFields\n  // (label \"Lead Manager\", type dropdown).\n  LEAD_MANAGER_FIELD: 'customLeadManager',\n\n  // Market/State custom field (dropdown) \u2014 CONFIRMED present. Part B can write\n  // the form's Market/State here (values must match the dropdown's options).\n  MARKET_STATE_FIELD: 'customMarketState',\n\n  API_BASE: 'https://api.followupboss.com/v1',\n};\n\n// \u2500\u2500 Slack channel registry (STAGING + PROD) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Two environments because the team already created `staging-*` channels for\n// testing (confirmed live 2026-06-14). Test against STAGING, then flip\n// CHANNEL_ENV to 'prod' at cutover. Post by immutable ID (names rename \u2192 silent\n// breakage); staging IDs are real (fetched), prod IDs are filled as channels are\n// created / the bot is invited.  `''` id \u2192 the Slack node posts by name instead.\nconst CHANNEL_ENV = 'staging'; // 'staging' | 'prod'  \u2190 flip to 'prod' for go-live\n\n// Prod IDs fetched live 2026-06-14 after the bot was invited (most are PRIVATE).\nconst CHANNELS = {\n  newLeads:     { prod: { name: 'all-ahb-new-leads',  id: 'C0PORTFOL01' },   staging: { name: 'staging-ahb-new-leads',     id: 'C0PORTFOL11' } },\n  underwriter:  { prod: { name: 'underwriter-to-dos', id: 'C0PORTFOL02' },   staging: { name: 'staging-underwriter-to-dos', id: 'C0PORTFOL12' } },\n  closersChat:  { prod: { name: 'closers-chat',       id: 'C0PORTFOL03' },   staging: { name: 'staging-closers-chat',       id: 'C0PORTFOL13' } },\n  tc:         { prod: { name: 'tc-to-dos',         id: 'C0PORTFOL04' },   staging: { name: 'staging-tc-to-dos',        id: 'C0PORTFOL14' } },\n  dispo:        { prod: { name: 'dispo-external-chat', id: 'C0PORTFOL05' },   staging: { name: 'staging-internal-dispo-chat', id: 'C0PORTFOL15' } }, // prod = dispo-EXTERNAL (SOW template was right)\n  teamWins:     { prod: { name: 'team-wins',           id: 'C0PORTFOL06' },   staging: { name: 'team-wins',                  id: 'C0PORTFOL06' } }, // no staging variant\n  leadManagers: { prod: { name: 'lead-managers',       id: 'C0PORTFOL07' },   staging: { name: '',                          id: '' } },\n  closerReyes:    { prod: { name: 'closer-deals-reyes',    id: 'C0PORTFOL08' },   staging: { name: '',                          id: '' } },\n  closerFlora:   { prod: { name: 'closer-deals-flora',   id: 'C0PORTFOL09' },   staging: { name: '',                          id: '' } },\n  closerMarco:   { prod: { name: 'closer-deals-marco',   id: 'C0PORTFOL10' },   staging: { name: '',                          id: '' } },\n  // (closer-deals-nick removed 2026-06-14 \u2014 Ned no longer with the team)\n};\n\n// Resolve a channel for the active env; fall back to prod if no staging variant.\nfunction chan(key) {\n  const c = CHANNELS[key];\n  if (!c) return { name: key, id: '' };\n  const e = c[CHANNEL_ENV] || {};\n  if (e.name) return e;\n  return c.prod || { name: key, id: '' };\n}\n\n// \u2500\u2500 Closer routing: match on the FUB `assignedTo` display name (case-insensitive\n//    \"contains\"). Order matters only if names overlap (they don't here). \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst CLOSER_ROUTING = [\n  { contains: 'reyes',  channelKey: 'closerReyes'  },\n  { contains: 'flora', channelKey: 'closerFlora' },\n  { contains: 'marco', channelKey: 'closerMarco' },\n];\n// If assignedTo is set but matches none of the above \u2192 ADDED SAFETY (not in SOW):\n// route to leadManagers with an \"unmapped closer\" note instead of dropping it.\nconst CLOSER_FALLBACK_CHANNEL = 'leadManagers';\n\n// \u2500\u2500 Stage routes. `key` is an internal id; `stage` is the EXACT FUB stage string\n//    (CONFIRM every one against GET /v1/stages \u2014 filters must match exactly). \u2500\u2500\u2500\n// trigger: 'created'  \u2192 fires on contact-created webhook (peopleCreated)\n//          'stage'    \u2192 fires on stage-changed webhook (peopleStageUpdated)\n// channel: a CHANNELS key, OR 'BY_CLOSER' to route via CLOSER_ROUTING.\nconst ROUTES = [\n  {\n    key: 'route1_new_lead',\n    trigger: 'created',\n    stage: 'Lead',\n    channel: 'newLeads',\n    template: 'newLead',\n    requiresCloser: false,\n  },\n  {\n    key: 'route2_pending_closer',\n    trigger: 'stage',\n    stage: 'Pending Closer Contact',\n    channel: 'BY_CLOSER',\n    template: 'prequalified',\n    requiresCloser: true, // missing assignedTo \u2192 catch-all warning\n  },\n  {\n    key: 'route3_needs_uw',\n    trigger: 'stage',\n    stage: 'Needs Underwriting',\n    channel: 'underwriter',\n    template: 'underwritingRequest',\n    requiresCloser: false,\n  },\n  {\n    key: 'route4_make_offer',\n    trigger: 'stage',\n    // CONFIRM: routes table calls the STAGE \"Closer Needs To Make Offer\"; the\n    // message template titles it \"OFFER PROVIDED BY UW!\". We treat the table as\n    // the trigger stage and the template title as the heading. Verify the real\n    // stage name + intent with Marco.\n    stage: 'Closer Needs To Make Offer',\n    channel: 'BY_CLOSER',\n    template: 'offerProvidedByUw',\n    requiresCloser: true,\n  },\n  {\n    key: 'route5_offer_made_submitted',\n    trigger: 'stage',\n    stage: 'Offer Submitted - Waiting to Hear Back', // CONFIRMED stage id=41\n    channel: 'closersChat',\n    template: 'offerMade',\n    requiresCloser: false,\n  },\n  // 2026-06-16: Marco CONFIRMED \"Offer Rejected - Future Follow Up\" (id=40) should\n  // ALSO notify #closers-chat (distinct \"OFFER REJECTED\" message). Moved out of\n  // NO_NOTIFY_STAGES; this is the second Route-5 stage.\n  {\n    key: 'route5b_offer_rejected',\n    trigger: 'stage',\n    stage: 'Offer Rejected - Future Follow Up',\n    channel: 'closersChat',\n    template: 'offerRejected',\n    requiresCloser: false,\n  },\n  {\n    key: 'route6_needs_contract',\n    trigger: 'stage',\n    stage: 'Needs Contract (Automatically Requested To TC)',\n    channel: 'tc',\n    template: 'contractRequest',\n    requiresCloser: false,\n  },\n  {\n    key: 'route7_contract_sent',\n    trigger: 'stage',\n    stage: 'Contract Sent',\n    // 2026-06-16: Marco CONFIRMED \u2192 #closers-chat (was tc-to-dos in the SOW table).\n    channel: 'closersChat',\n    template: 'contractSent',\n    requiresCloser: false,\n  },\n  {\n    key: 'route8_under_contract',\n    trigger: 'stage',\n    stage: 'Under Contract',\n    channel: 'dispo',\n    template: 'underContract',\n    requiresCloser: false,\n  },\n  {\n    key: 'route9_closed',\n    trigger: 'stage',\n    stage: 'Closed',\n    channel: 'teamWins',\n    template: 'closed',\n    requiresCloser: false,\n  },\n];\n\n// Stages that EXPLICITLY get no notification (fall through silently by design).\nconst NO_NOTIFY_STAGES = [\n  'No Contact Made',\n  'Cold - Follow Up',\n  // 'Offer Rejected - Future Follow Up' \u2014 MOVED to a notifying route (Marco 2026-06-16).\n  'Hot Leads',\n  'Dead (Previous Deal)',\n  'Dead/Already Sold',\n  'Other Contacts',\n  'Title Companies',\n  'Lawyers',\n  'Buyers List',\n  'Buyers List (Real Estate Broker)',\n  'Trash',\n];\n\n// \u2500\u2500 Part B (cold-lead intake form) constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst INTAKE = {\n  HARDCODED_STAGE: 'Lead',          // set in the automation, never on the form\n  HARDCODED_TAG: 'Cold Lead - SMS', // tag every form lead so managers can spot it\n  // Dropdown values for the form (edit to the agency's real campaigns / roster).\n  MARKETS: ['PA', 'NJ', 'IN', 'TN', 'NC', 'Other'],\n  PROPERTY_TYPES: ['SFH', 'Condo', 'Multi', 'Land', 'Other'],\n  // Placeholders \u2014 REPLACE with the agency's real campaign + agent names:\n  CAMPAIGNS: ['Spring Absentee PA', 'Probate NJ Q2', 'High-Equity IN', 'Tired Landlord TN', 'Pre-Foreclosure NC'],\n  SMS_AGENTS: ['Agent A', 'Agent B', 'Agent C'],\n};\n\n// Export for Node (tests) and ignore in n8n (n8n Code nodes embed copies).\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = {\n    FUB, CHANNELS, CHANNEL_ENV, chan, CLOSER_ROUTING, CLOSER_FALLBACK_CHANNEL,\n    ROUTES, NO_NOTIFY_STAGES, INTAKE,\n  };\n}\n\n\n// ----------------------------------------\n\n/**\n * phone.js \u2014 normalize a free-text US phone into E.164 (+1XXXXXXXXXX).\n *\n * Why: FUB phone/email search is EXACT-match with no documented normalization\n * (research gotcha). If the form gives \"(215) 555 1234\" and FUB stores\n * \"+12155551234\", a naive search misses the dupe and creates a second record.\n * Canonicalize on BOTH the write and the search so dedupe actually works.\n */\n\nfunction normalizeToE164(raw) {\n  if (raw == null) return { e164: null, valid: false, reason: 'empty' };\n  const hadPlus = String(raw).trim().startsWith('+');\n  const digits = String(raw).replace(/\\D/g, '');\n\n  if (digits.length === 10) {\n    return { e164: '+1' + digits, valid: true };\n  }\n  if (digits.length === 11 && digits.startsWith('1')) {\n    return { e164: '+' + digits, valid: true };\n  }\n  // Already international (kept +) or longer than NANP \u2014 best-effort passthrough.\n  if (hadPlus && digits.length >= 11 && digits.length <= 15) {\n    return { e164: '+' + digits, valid: true };\n  }\n  return { e164: null, valid: false, reason: `unexpected length ${digits.length}` };\n}\n\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { normalizeToE164 };\n}\n\n\n// ----------------------------------------\n\n/**\n * fields.js \u2014 pull the notification fields out of a raw FUB person record\n * (the object returned by GET /v1/people/{id}?fields=allFields).\n *\n * Reflects the REAL FUB schema confirmed in research:\n *  - names: firstName / lastName (or `name`)\n *  - addresses: ARRAY of { street, city, state, code(=ZIP), ... }  (NOT flat)\n *  - source: string (immutable after create)\n *  - assignedTo: display name string\n *  - custom fields: top-level `custom<CamelLabel>` key, only present with ?fields=allFields\n */\n\n\nfunction fullName(p) {\n  if (p && p.name && String(p.name).trim()) return String(p.name).trim();\n  const fn = (p && p.firstName) || '';\n  const ln = (p && p.lastName) || '';\n  return `${fn} ${ln}`.trim();\n}\n\nfunction primaryAddress(p) {\n  const arr = (p && Array.isArray(p.addresses)) ? p.addresses : [];\n  const a = arr[0];\n  if (!a) return '';\n  const line = [a.street, a.city, a.state].filter(Boolean).join(', ');\n  return [line, a.code].filter(Boolean).join(' ').trim();\n}\n\nfunction fubLink(p) {\n  const id = p && (p.id != null ? p.id : '');\n  return id === '' ? '' : `${FUB.PERSON_URL_BASE}${id}`;\n}\n\n/** Build the field bag the message templates consume. */\nfunction extractFields(p) {\n  return {\n    sellerName: fullName(p),\n    address: primaryAddress(p),\n    source: (p && p.source) || '',\n    closer: (p && p.assignedTo) || '',\n    leadManager: (p && p[FUB.LEAD_MANAGER_FIELD]) || '',\n    fubLink: fubLink(p),\n    stage: (p && p.stage) || '',\n    personId: (p && p.id) || '',\n  };\n}\n\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { extractFields, fullName, primaryAddress, fubLink };\n}\n\n\n// ----------------------------------------\n\n/**\n * format.js \u2014 build the Slack message (mrkdwn text + Block Kit blocks) for each\n * route, verbatim from the SOW \u00a75 templates.\n *\n * Slack formatting rules baked in (research gotchas):\n *  - bold is *single* asterisks (NOT **double**)\n *  - links are <url|text> (NOT [text](url))\n *  - always set a plain-text `text` fallback even when using blocks\n */\n\n// Template registry: heading + ordered [label, fieldKey] rows.\n// fieldKey maps into the bag from fields.extractFields().\nconst TEMPLATES = {\n  newLead: {\n    heading: 'New Seller Lead :rocket:',\n    rows: [\n      ['Marketing Campaign', 'source'],\n      ['Seller Name', 'sellerName'],\n      ['Address', 'address'],\n      ['Lead Manager', 'leadManager'],\n    ],\n  },\n  prequalified: {\n    heading: ':telephone_receiver: PREQUALIFIED LEAD ASSIGNED TO YOU',\n    rows: [\n      ['Seller Name', 'sellerName'],\n      ['Address', 'address'],\n      ['Source', 'source'],\n      ['Lead Manager', 'leadManager'],\n    ],\n  },\n  underwritingRequest: {\n    heading: 'UNDERWRITING REQUEST',\n    rows: [\n      ['Seller Name', 'sellerName'],\n      ['Closer', 'closer'],\n      ['Address', 'address'],\n      ['Source', 'source'],\n    ],\n  },\n  offerProvidedByUw: {\n    heading: 'OFFER PROVIDED BY UW!',\n    rows: [\n      ['Seller Name', 'sellerName'],\n      ['Closer', 'closer'],\n      ['Address', 'address'],\n      ['Source', 'source'],\n    ],\n  },\n  offerMade: {\n    heading: ':envelope_with_arrow: OFFER MADE',\n    rows: [\n      ['Closer', 'closer'],\n      ['Seller Name', 'sellerName'],\n      ['Address', 'address'],\n    ],\n  },\n  offerRejected: {\n    heading: 'OFFER REJECTED',\n    rows: [\n      ['Closer', 'closer'],\n      ['Seller Name', 'sellerName'],\n      ['Address', 'address'],\n    ],\n  },\n  contractRequest: {\n    heading: ':memo: CONTRACT REQUEST',\n    rows: [\n      ['Seller Name', 'sellerName'],\n      ['Closer', 'closer'],\n      ['Address', 'address'],\n    ],\n  },\n  contractSent: {\n    heading: ':white_check_mark: CONTRACT SENT',\n    rows: [\n      ['Seller Name', 'sellerName'],\n      ['Closer', 'closer'],\n      ['Address', 'address'],\n    ],\n  },\n  underContract: {\n    heading: 'NEW DEAL UNDER CONTRACT!',\n    rows: [\n      ['Seller Name', 'sellerName'],\n      ['Closer', 'closer'],\n      ['Address', 'address'],\n      ['Source', 'source'],\n    ],\n  },\n  closed: {\n    heading: ':tada: Congrats to the closer!',\n    rows: [\n      ['Closer', 'closer'],\n      ['Property', 'address'],\n      ['Source', 'source'],\n    ],\n  },\n};\n\nconst FUB_LINK_LABEL = 'Open in FUB';\n\nfunction fubLinkMrkdwn(url) {\n  return url ? `<${url}|${FUB_LINK_LABEL}>` : '_(no FUB link)_';\n}\n\n/** The catch-all warning for a closer-routed stage with no Assigned To. */\nfunction buildCatchall(stage, fields) {\n  const text =\n    `:warning: Lead moved to *${stage}* with no closer assigned\\n` +\n    `*Seller Name:* ${fields.sellerName || '\u2014'}\\n` +\n    `*FUB Link:* ${fubLinkMrkdwn(fields.fubLink)}`;\n  const blocks = [\n    { type: 'section', text: { type: 'mrkdwn', text: `:warning: *Lead moved to ${stage} with no closer assigned*` } },\n    { type: 'section', fields: [\n      { type: 'mrkdwn', text: `*Seller Name:*\\n${fields.sellerName || '\u2014'}` },\n      { type: 'mrkdwn', text: `*FUB Link:*\\n${fubLinkMrkdwn(fields.fubLink)}` },\n    ] },\n  ];\n  return { text, blocks };\n}\n\n/** Build a normal route message. templateKey \u2208 keys of TEMPLATES. */\nfunction buildMessage(templateKey, fields) {\n  if (templateKey === 'catchall') return buildCatchall(fields.stage, fields);\n  const tpl = TEMPLATES[templateKey];\n  if (!tpl) throw new Error(`unknown template \"${templateKey}\"`);\n\n  // mrkdwn text (fallback + simple clients)\n  const lines = [`*${tpl.heading}*`];\n  for (const [label, key] of tpl.rows) lines.push(`*${label}:* ${fields[key] || '\u2014'}`);\n  lines.push(`*FUB Link:* ${fubLinkMrkdwn(fields.fubLink)}`);\n  const text = lines.join('\\n');\n\n  // Block Kit: heading section + two-column fields + link section\n  const fieldBlocks = tpl.rows.map(([label, key]) => ({\n    type: 'mrkdwn',\n    text: `*${label}:*\\n${fields[key] || '\u2014'}`,\n  }));\n  const blocks = [\n    { type: 'section', text: { type: 'mrkdwn', text: `*${tpl.heading}*` } },\n    { type: 'section', fields: fieldBlocks },\n    { type: 'section', text: { type: 'mrkdwn', text: `*FUB Link:* ${fubLinkMrkdwn(fields.fubLink)}` } },\n  ];\n  return { text, blocks };\n}\n\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { buildMessage, buildCatchall, TEMPLATES };\n}\n\n\n// ----------------------------------------\n\n/**\n * routing.js \u2014 decide where (if anywhere) a FUB event should notify.\n *\n * Pure, testable. The n8n Switch/Code nodes and the Make Router filters both\n * mirror this logic. Input is the normalized event; output is a decision the\n * caller turns into a Slack post.\n */\n\n\nconst norm = (s) => String(s == null ? '' : s).trim();\nconst lc = (s) => norm(s).toLowerCase();\n\n/** Resolve a closer's channel from the FUB assignedTo display name. */\nfunction resolveCloserChannel(assignedTo) {\n  const a = lc(assignedTo);\n  if (!a) return { channelKey: null, isFallback: false, missing: true };\n  for (const r of CLOSER_ROUTING) {\n    if (a.includes(r.contains)) return { channelKey: r.channelKey, isFallback: false, missing: false };\n  }\n  // assignedTo is set but matches no known closer \u2192 safety net, never drop it.\n  return { channelKey: CLOSER_FALLBACK_CHANNEL, isFallback: true, missing: false };\n}\n\nfunction isNoNotify(stage) {\n  const s = lc(stage);\n  return NO_NOTIFY_STAGES.some((x) => lc(x) === s);\n}\n\nfunction findRoute(triggerType, stage) {\n  const s = lc(stage);\n  return ROUTES.find((r) => r.trigger === triggerType && lc(r.stage) === s) || null;\n}\n\n/**\n * decide(event) \u2192 decision\n *   event = { type: 'created'|'stage', stage, assignedTo }\n *   decision = {\n *     action: 'notify' | 'catchall' | 'skip',\n *     routeKey, template, channelKey, channelName,\n *     warnUnmappedCloser (bool), reason\n *   }\n */\nfunction decide(event) {\n  const type = event && event.type;\n  const stage = norm(event && event.stage);\n  const assignedTo = norm(event && event.assignedTo);\n\n  if (type === 'stage' && isNoNotify(stage)) {\n    return { action: 'skip', reason: `no-notify stage \"${stage}\"` };\n  }\n\n  const route = findRoute(type, stage);\n  if (!route) {\n    return { action: 'skip', reason: `no route for trigger=${type} stage=\"${stage}\" (unknown/future stage falls through by design)` };\n  }\n\n  // Catch-all: closer-routed stage with no Assigned To \u2192 warn lead-managers.\n  if (route.requiresCloser && !assignedTo) {\n    return {\n      action: 'catchall',\n      routeKey: route.key,\n      template: 'catchall',\n      channelKey: 'leadManagers',\n      channelName: chan('leadManagers').name,\n      reason: `${route.stage} with no closer assigned`,\n    };\n  }\n\n  let channelKey = route.channel;\n  let warnUnmappedCloser = false;\n  if (route.channel === 'BY_CLOSER') {\n    const c = resolveCloserChannel(assignedTo);\n    channelKey = c.channelKey;\n    warnUnmappedCloser = c.isFallback;\n  }\n\n  return {\n    action: 'notify',\n    routeKey: route.key,\n    template: route.template,\n    channelKey,\n    channelName: chan(channelKey).name,\n    warnUnmappedCloser,\n    reason: 'routed',\n  };\n}\n\nif (typeof module !== 'undefined' && module.exports) {\n  module.exports = { decide, resolveCloserChannel, isNoNotify, findRoute };\n}\n\n\n// ===== n8n entrypoint (Part A) =====\nconst person = $json;                       // FUB GET /v1/people/{id}?fields=allFields\nconst ev = (($('FUB Webhook').item.json.body) || {}).event || '';\nconst type = ev === 'peopleCreated' ? 'created' : (ev === 'peopleStageUpdated' ? 'stage' : 'unknown');\nconst decision = decide({ type: type, stage: person.stage, assignedTo: person.assignedTo });\nif (decision.action === 'skip') {\n  return { json: { action: 'skip', reason: decision.reason, stage: person.stage } };\n}\nconst f = extractFields(person);\nf.stage = person.stage;\nconst msg = buildMessage(decision.template, f);\nconst ch = chan(decision.channelKey); // staging/prod aware (CHANNEL_ENV in config)\nreturn { json: {\n  action: decision.action,\n  routeKey: decision.routeKey,\n  channelKey: decision.channelKey,\n  channelId: ch.id || '',\n  channelName: ch.name,\n  text: msg.text,\n  blocks: msg.blocks,\n  warnUnmappedCloser: !!decision.warnUnmappedCloser,\n  reason: decision.reason,\n} };\n"
      },
      "id": "node-0003-0000-4000-8000-000000000000",
      "name": "Route + Build Message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        680,
        300
      ]
    },
    {
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "node-0004-0000-4000-8000-000000000000",
              "leftValue": "={{ $json.action }}",
              "rightValue": "skip",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ]
        }
      },
      "id": "node-0005-0000-4000-8000-000000000000",
      "name": "Skip?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        900,
        300
      ]
    },
    {
      "parameters": {},
      "id": "node-0006-0000-4000-8000-000000000000",
      "name": "No notification",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1120,
        200
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "={{ $json.channelId }}",
          "mode": "id"
        },
        "messageType": "block",
        "blocksUi": "={{ JSON.stringify({ blocks: $json.blocks }) }}",
        "otherOptions": {
          "text": "={{ $json.text }}"
        }
      },
      "id": "node-0007-0000-4000-8000-000000000000",
      "name": "Post to Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.5,
      "position": [
        1120,
        400
      ],
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000
    }
  ],
  "connections": {
    "FUB Webhook": {
      "main": [
        [
          {
            "node": "FUB Get Person",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FUB Get Person": {
      "main": [
        [
          {
            "node": "Route + Build Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route + Build Message": {
      "main": [
        [
          {
            "node": "Skip?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Skip?": {
      "main": [
        [
          {
            "node": "No notification",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all"
  },
  "active": false,
  "meta": {
    "templateId": "ahb-fub-slack"
  }
}

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

AHB — Part A: FUB → Slack stage notifications. Uses httpRequest, slack. Webhook trigger; 10 nodes.

Source: https://github.com/Alexey0424/real-estate-crm-automation-suite/blob/main/n8n/workflows/partA-fub-slack.json — original creator credit. Request a take-down →

More Slack & Telegram workflows → · Browse all categories →

Related workflows

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

Slack & Telegram

HR teams, IT Operations, and System Administrators managing employee onboarding at scale. It’s perfect if you use Odoo 18 to trigger account requests and need Redmine + GitLab accounts created instant

HTTP Request, Slack
Slack & Telegram

This workflow is a complete, production-ready solution for recovering abandoned carts in Shopify stores using a multi-channel, multi-touch approach. It automates personalized follow-ups via Email, SMS

HTTP Request, Shopify, SendGrid +5
Slack & Telegram

Backbrief: transcripts (Zoom webhook -> Slack + vault). Uses httpRequest, slack. Webhook trigger; 52 nodes.

HTTP Request, Slack
Slack & Telegram

This workflow automates end-to-end research analysis by coordinating multiple AI models—including NVIDIA NIM (Llama), OpenAI GPT-4, and Claude to analyze uploaded documents, extract insights, and gene

HTTP Request, Postgres, Slack +1
Slack & Telegram

Are you tired of the repetitive dance between git push, creating a pull request in GitHub, updating the corresponding task in JIRA, and then manually notifying your team in Slack, or Notion?

HTTP Request, Stop And Error, Jira +2