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 →
{
"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.
httpBasicAuthslackApi
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 →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
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
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
Backbrief: transcripts (Zoom webhook -> Slack + vault). Uses httpRequest, slack. Webhook trigger; 52 nodes.
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
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?