This workflow corresponds to n8n.io template #16100 — we link there as the canonical source.
This workflow follows the Google Sheets → 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 →
{
"name": "LinkedIn Profile \u2192 Candidate Row (powered by easybits & Slack)",
"tags": [],
"nodes": [
{
"id": "044694b7-8151-42a7-934f-08ad709cf126",
"name": "On File Shared: Slack Channel",
"type": "n8n-nodes-base.slackTrigger",
"position": [
416,
256
],
"parameters": {
"options": {},
"trigger": [
"file_share"
],
"channelId": {
"__rl": true,
"mode": "list",
"value": ""
},
"downloadFiles": true
},
"credentials": {
"slackApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "c940de73-4ee6-4d60-85f2-f2758741335a",
"name": "easybits Extract: LinkedIn Profile",
"type": "@easybits/n8n-nodes-extractor.easybitsExtractor",
"position": [
1424,
256
],
"parameters": {},
"credentials": {
"easybitsExtractorApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "599b275e-b4e6-4bd9-bb88-6386d8929adc",
"name": "Normalize: Candidate Fields",
"type": "n8n-nodes-base.code",
"position": [
1760,
256
],
"parameters": {
"jsCode": "// \u2500\u2500 Helpers (easybits Extractor output) \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// Extractor wraps everything in a `data` envelope and returns the\n// STRING \"null\" for missing fields, arrays as JSON-encoded or\n// comma-separated strings.\nconst d = $input.first().json.data;\n\nfunction isMissing(v) {\n return v === undefined || v === null || v === 'null' || v === '';\n}\n\nfunction toArray(v) {\n if (isMissing(v)) return [];\n if (Array.isArray(v)) return v.map(x => String(x).trim()).filter(Boolean);\n if (typeof v === 'string') {\n const s = v.trim();\n if (s.startsWith('[')) { // JSON-encoded array\n try {\n const parsed = JSON.parse(s);\n if (Array.isArray(parsed)) return parsed.map(x => String(x).trim()).filter(Boolean);\n } catch (e) { /* fall through to comma-split */ }\n }\n return s.split(',').map(x => x.trim()).filter(Boolean); // comma-split fallback\n }\n return [v];\n}\n\nconst get = (k) => (isMissing(d[k]) ? '' : String(d[k]).trim());\n\n// \u2500\u2500 Build one candidate row (same schema as CV onboarding output) \u2500\u2500\nreturn [{\n json: {\n name: get('name'),\n current_title: get('current_title'),\n current_company: get('current_company'),\n location: get('location'),\n total_years_experience: get('total_years_experience'),\n top_skills: toArray(d.top_skills).join(', '),\n last_roles: toArray(d.last_roles).join(' | '),\n education: get('education'),\n certifications: toArray(d.certifications).join(', '),\n summary: get('summary'),\n // \u2500\u2500 provenance: this is the field that makes the \"one database,\n // two intake paths\" story work. CV onboarding writes \"CV (applicant)\".\n source: 'LinkedIn (sourced)',\n added_at: new Date().toISOString(),\n }\n}];\n"
},
"typeVersion": 2
},
{
"id": "632e1e97-48ac-42a5-87d0-526afd3a1961",
"name": "Append Row: Candidate Sheet",
"type": "n8n-nodes-base.googleSheets",
"position": [
3104,
464
],
"parameters": {
"columns": {
"value": {},
"schema": [
{
"id": "name",
"type": "string",
"display": true,
"required": false,
"displayName": "name",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "current_title",
"type": "string",
"display": true,
"required": false,
"displayName": "current_title",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "current_company",
"type": "string",
"display": true,
"required": false,
"displayName": "current_company",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "location",
"type": "string",
"display": true,
"required": false,
"displayName": "location",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "total_years_experience",
"type": "string",
"display": true,
"required": false,
"displayName": "total_years_experience",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "top_skills",
"type": "string",
"display": true,
"required": false,
"displayName": "top_skills",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "last_roles",
"type": "string",
"display": true,
"required": false,
"displayName": "last_roles",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "education",
"type": "string",
"display": true,
"required": false,
"displayName": "education",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "certifications",
"type": "string",
"display": true,
"required": false,
"displayName": "certifications",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "summary",
"type": "string",
"display": true,
"required": false,
"displayName": "summary",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "source",
"type": "string",
"display": true,
"required": false,
"displayName": "source",
"defaultMatch": false,
"canBeUsedToMatch": true
},
{
"id": "added_at",
"type": "string",
"display": true,
"required": false,
"displayName": "added_at",
"defaultMatch": false,
"canBeUsedToMatch": true
}
],
"mappingMode": "autoMapInputData",
"matchingColumns": [],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {},
"operation": "append",
"sheetName": {
"__rl": true,
"mode": "name",
"value": ""
},
"documentId": {
"__rl": true,
"mode": "list",
"value": ""
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 4
},
{
"id": "d085060d-ba6a-410f-a070-aef054fecad3",
"name": "Slack: Get File Info",
"type": "n8n-nodes-base.slack",
"position": [
752,
256
],
"parameters": {
"fileId": "={{ $json.file_id }}",
"resource": "file",
"operation": "get"
},
"credentials": {
"slackApi": {
"name": "<your credential>"
}
},
"typeVersion": 2.4
},
{
"id": "c88db35e-05d1-428d-8865-5af9f3fa44d5",
"name": "Download File: From Slack",
"type": "n8n-nodes-base.httpRequest",
"position": [
1088,
256
],
"parameters": {
"url": "={{ $json.url_private_download }}",
"options": {
"response": {
"response": {
"responseFormat": "file"
}
}
},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "slackApi"
},
"credentials": {
"slackApi": {
"name": "<your credential>"
}
},
"typeVersion": 4.3
},
{
"id": "c3b5d7a3-37c0-4b45-aab6-07eb49bbbc78",
"name": "Post Confirmation: Slack (via API)",
"type": "n8n-nodes-base.httpRequest",
"position": [
3488,
464
],
"parameters": {
"url": "https://slack.com/api/chat.postMessage",
"method": "POST",
"options": {},
"jsonBody": "={\n \"channel\": \"{{ $('On File Shared: Slack Channel').item.json.channel_id }}\",\n \"thread_ts\": \"{{ $('Slack: Get File Info').item.json.shares.public[$('On File Shared: Slack Channel').item.json.channel_id][0].ts }}\",\n \"text\": \"\u2705 Added {{ $('Normalize: Candidate Fields').item.json.name }} to the candidate database\",\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"\u2705 Added *{{ $('Normalize: Candidate Fields').item.json.name }}* \u2014 _{{ $('Normalize: Candidate Fields').item.json.current_title }} @ {{ $('Normalize: Candidate Fields').item.json.current_company }}_ to the candidate database (sourced).\"\n },\n \"accessory\": {\n \"type\": \"button\",\n \"text\": { \"type\": \"plain_text\", \"text\": \"Open candidate sheet\" },\n \"url\": \"https://docs.google.com/spreadsheets/d/YOUR_SHEET_ID/edit\",\n \"style\": \"primary\"\n }\n }\n ]\n}",
"sendBody": true,
"specifyBody": "json",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "slackApi"
},
"credentials": {
"slackApi": {
"name": "<your credential>"
}
},
"typeVersion": 4.3
},
{
"id": "97e4904d-5471-4bdf-be31-9309b45aff6f",
"name": "Check: Duplicate?",
"type": "n8n-nodes-base.code",
"position": [
2432,
256
],
"parameters": {
"jsCode": "const newCandidate = $('Normalize: Candidate Fields').first().json;\nconst existingRows = $input.all()\n .map(item => item.json)\n .filter(row => row && row.name); // ignore the empty-item case\n\nconst normalize = (s) => String(s || '').trim().toLowerCase();\nconst key = (row) => normalize(row.name) + '|' + normalize(row.current_company);\nconst newKey = key(newCandidate);\n\nconst duplicate = existingRows.find(row => key(row) === newKey);\n\nreturn [{\n json: {\n ...newCandidate,\n is_duplicate: Boolean(duplicate),\n existing_added_at: duplicate\n ? new Date(duplicate.added_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })\n : null,\n }\n}];"
},
"typeVersion": 2
},
{
"id": "918ef7b5-898c-4680-9f08-45726830173f",
"name": "IF: Already Sourced?",
"type": "n8n-nodes-base.if",
"position": [
2768,
256
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "30674e2c-5404-40a4-b4aa-7ed2d2d51d61",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ $json.is_duplicate }}",
"rightValue": ""
}
]
}
},
"typeVersion": 2.3
},
{
"id": "419f72a7-931a-494f-83e8-ef8a3c090121",
"name": "Post Duplicate Warning: Slack (via API)",
"type": "n8n-nodes-base.httpRequest",
"position": [
3104,
32
],
"parameters": {
"url": "https://slack.com/api/chat.postMessage",
"method": "POST",
"options": {},
"jsonBody": "={\n \"channel\": \"{{ $('On File Shared: Slack Channel').item.json.channel_id }}\",\n \"thread_ts\": \"{{ $('Slack: Get File Info').item.json.shares.public[$('On File Shared: Slack Channel').item.json.channel_id][0].ts }}\",\n \"text\": \"\u26a0\ufe0f {{ $json.name }} is already in the candidate database\",\n \"blocks\": [\n {\n \"type\": \"section\",\n \"text\": {\n \"type\": \"mrkdwn\",\n \"text\": \"\u26a0\ufe0f *{{ $json.name }}* is already in the candidate database \u2014 sourced on {{ $json.existing_added_at }}. Nothing was added.\"\n },\n \"accessory\": {\n \"type\": \"button\",\n \"text\": { \"type\": \"plain_text\", \"text\": \"Open candidate sheet\" },\n \"url\": \"https://docs.google.com/spreadsheets/d/YOUR_SHEET_ID/edit\"\n }\n }\n ]\n}",
"sendBody": true,
"specifyBody": "json",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "slackApi"
},
"credentials": {
"slackApi": {
"name": "<your credential>"
}
},
"typeVersion": 4.3
},
{
"id": "d40dc76a-1111-4f7f-9244-fbab562a136c",
"name": "Read: Candidate Sheet",
"type": "n8n-nodes-base.googleSheets",
"position": [
2096,
256
],
"parameters": {
"options": {},
"sheetName": {
"__rl": true,
"mode": "name",
"value": ""
},
"documentId": {
"__rl": true,
"mode": "list",
"value": ""
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 4.7,
"alwaysOutputData": true
},
{
"id": "369ec522-3af4-4db0-a297-42e2523c3d87",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-352,
-288
],
"parameters": {
"width": 646,
"height": 1040,
"content": "# \ud83d\udd17 LinkedIn Profile \u2192 Candidate Row\n(powered by easybits & Slack)\n\n## What This Workflow Does\nA recruiter sources a candidate on LinkedIn, then drops either a **screenshot** of the profile or LinkedIn's official **Save to PDF** export into a Slack channel. The workflow extracts the profile into the **same schema as CV applicants**, deduplicates against the existing candidate database, and posts a confirmation (or duplicate warning) back into the Slack thread with a button straight to the sheet.\n\n## How It Works\n1. **Drop** \u2013 Recruiter uploads a LinkedIn screenshot or PDF into the watched Slack channel\n2. **Hydrate** \u2013 `files.info` is called to expand the minimal trigger payload\n3. **Download** \u2013 HTTP Request pulls the binary from Slack with bot-token auth\n4. **Extract** \u2013 easybits Extractor pulls 10 structured fields straight from the binary\n5. **Normalize** \u2013 Code node applies `toArray()` / `isMissing()` and tags `source = LinkedIn (sourced)`\n6. **Dedup check** \u2013 Read the candidate sheet, compare on `name + current_company`\n7. **Route** \u2013 IF node sends duplicates to a warning, new candidates to the Append step\n8. **Confirm** \u2013 Bot replies in-thread with the candidate name, role, and a button to the sheet\n\n## Setup Guide\n1. Install the easybits Extractor (n8n Cloud: built in; self-hosted: `@easybits/n8n-nodes-extractor`)\n2. Create a Slack app with `files:read`, `channels:history` (or `groups:history`), and `chat:write` scopes\n3. Subscribe the app to the `file_shared` bot event and paste the n8n trigger URL into Event Subscriptions\n4. Invite the bot to the watched channel\n5. Connect Slack, easybits, and Google Sheets credentials in n8n\n6. Set the watched channel on the trigger; set the document ID and tab on both Sheets nodes\n7. Toggle **Always Output Data** on `Read: Candidate Sheet` so the first-ever candidate still flows through\n8. Confirm the candidate sheet headers match the Normalize node output columns\n\n## Known Behavior\n- Slack's `file_shared` event payload is minimal \u2013 `Slack: Get File Info` is required to get `url_private_download`\n- The n8n Slack node has a long-standing bug where Block Kit messages render only the fallback text, so both confirmation paths POST directly to `chat.postMessage`\n- LinkedIn screenshots truncate Experience and Skills at the `Show all` fold; PDF exports show the full history. The Extractor honestly reflects what's visible."
},
"typeVersion": 1
},
{
"id": "d7291bf4-9e91-4d2e-a8f5-03e6d9dc63a0",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
304,
16
],
"parameters": {
"color": 7,
"width": 320,
"height": 416,
"content": "## \ud83d\udce5 Slack Trigger: File Share\nFires on the `file_shared` event for **one specific channel**. The minimal event payload only contains `file_id` and `channel_id` \u2013 `url_private_download` lives one node downstream via `files.info`. The bot must be a member of the channel and have `files:read`."
},
"typeVersion": 1
},
{
"id": "9801bea3-59d9-4b1f-9464-d5b4abad9128",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
640,
16
],
"parameters": {
"color": 7,
"width": 320,
"height": 416,
"content": "## \ud83d\udd0d Slack: Hydrate File Metadata\nCalls `files.info` to expand the minimal `file_shared` event into a full file object. **Mandatory in this n8n version** \u2013 the trigger's `Download Files` toggle doesn't actually populate `url_private_download` on its own. The hydrated fields land at the **top level** of the output (no `file` wrapper)."
},
"typeVersion": 1
},
{
"id": "25692819-b132-4011-b81d-4c96b1baaab2",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
976,
16
],
"parameters": {
"color": 7,
"width": 320,
"height": 416,
"content": "## \u2b07\ufe0f Download File: From Slack\nHTTP GET on `url_private_download` with the bot token in `Authorization`. **Response Format** = `File`, **Put Output File in Field** = `data` \u2013 what the Extractor reads from. The credential here must match the bot that received the original event, or Slack returns 403."
},
"typeVersion": 1
},
{
"id": "7b5eef15-bb1b-4376-be38-3245dcb43d44",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
1312,
16
],
"parameters": {
"color": 7,
"width": 320,
"height": 416,
"content": "## \ud83e\udde9 easybits Extract: LinkedIn Profile\nReads the binary on `data`. Output wrapped in a `data` envelope (`$input.first().json.data`). **10 fields = free-plan ceiling.** Array fields use the **1-3 word noun phrase** rule so the About section doesn't pollute `top_skills`. Handles both PDFs and screenshots (multimodal)."
},
"typeVersion": 1
},
{
"id": "775bfd44-13ae-4311-90ba-40980607581c",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"position": [
1648,
16
],
"parameters": {
"color": 7,
"width": 320,
"height": 416,
"content": "## \ud83d\udee0\ufe0f Normalize: Candidate Fields\n`toArray()` handles arrays that come back JSON-encoded **or** comma-separated. `isMissing()` catches the string `\"null\"`. Tags `source = LinkedIn (sourced)` and `added_at` so sourced candidates are comparable to CV applicants in the same sheet."
},
"typeVersion": 1
},
{
"id": "cc74d88a-5ebf-497a-bc9e-75f6187fe193",
"name": "Sticky Note6",
"type": "n8n-nodes-base.stickyNote",
"position": [
1984,
16
],
"parameters": {
"color": 7,
"width": 320,
"height": 416,
"content": "## \ud83d\udcd6 Read: Candidate Sheet\nReads all existing candidate rows for the dedup check. **`Always Output Data` is toggled ON** on this node (settings gear) so the very first candidate \u2013 when the sheet is empty \u2013 still flows through to the Code node instead of dead-ending."
},
"typeVersion": 1
},
{
"id": "ece487ed-e9c2-4433-8c7d-c993e7659645",
"name": "Sticky Note7",
"type": "n8n-nodes-base.stickyNote",
"position": [
2320,
16
],
"parameters": {
"color": 7,
"width": 320,
"height": 416,
"content": "## \ud83e\uddee Check: Duplicate?\nBuilds a normalized key of `name + current_company` (lowercased, trimmed) and scans the existing rows for a match. Emits the candidate object plus `is_duplicate` and `existing_added_at` (the prior sourcing date if found). Filters out the empty placeholder item that `Always Output Data` produces."
},
"typeVersion": 1
},
{
"id": "5eaabb47-8762-440c-8168-e674e54cdae8",
"name": "Sticky Note8",
"type": "n8n-nodes-base.stickyNote",
"position": [
2656,
16
],
"parameters": {
"color": 7,
"width": 320,
"height": 416,
"content": "## \ud83d\udd00 IF: Already Sourced?\nRoutes on `is_duplicate`. **Top branch (true)** \u2192 duplicate warning, no row written. **Bottom branch (false)** \u2192 append the new row and post the confirmation."
},
"typeVersion": 1
},
{
"id": "0acc85e0-2543-474d-893d-f66e4a045671",
"name": "Sticky Note9",
"type": "n8n-nodes-base.stickyNote",
"position": [
2992,
-208
],
"parameters": {
"color": 7,
"width": 320,
"height": 416,
"content": "## \u26a0\ufe0f Post Duplicate Warning: Slack (via API)\nSame HTTP-direct pattern as the confirmation node \u2013 `chat.postMessage` with text + blocks. Posts an in-thread warning showing **when the candidate was originally sourced**, with a button to the sheet. No row is written."
},
"typeVersion": 1
},
{
"id": "ec3145f8-aaf3-48f6-bfc6-5d85c2bac216",
"name": "Sticky Note10",
"type": "n8n-nodes-base.stickyNote",
"position": [
2992,
224
],
"parameters": {
"color": 7,
"width": 320,
"height": 416,
"content": "## \u2795 Append Row: Candidate Sheet\nNew candidates only (false branch from the IF). Uses `autoMapInputData` so the Normalize node's output keys must exactly match the sheet's column headers. Same sheet the CV Onboarding workflow writes to \u2013 one database, two intake paths."
},
"typeVersion": 1
},
{
"id": "5d37c181-40ad-448c-9f94-c3137681c31f",
"name": "Sticky Note11",
"type": "n8n-nodes-base.stickyNote",
"position": [
3328,
224
],
"parameters": {
"color": 7,
"width": 432,
"height": 416,
"content": "## \u2705 Post Confirmation: Slack (via API)\nBypasses the n8n Slack node and POSTs directly to `chat.postMessage` because the Slack node has a known bug that drops Block Kit and only sends the fallback text. Replies in-thread to the original file drop using the `ts` pulled from `Slack: Get File Info`'s `shares.public[channel_id][0].ts`. Sends both `text` (notification fallback) and `blocks` (rich message + sheet button)."
},
"typeVersion": 1
}
],
"active": false,
"settings": {
"availableInMCP": false,
"executionOrder": "v1"
},
"connections": {
"Check: Duplicate?": {
"main": [
[
{
"node": "IF: Already Sourced?",
"type": "main",
"index": 0
}
]
]
},
"IF: Already Sourced?": {
"main": [
[
{
"node": "Post Duplicate Warning: Slack (via API)",
"type": "main",
"index": 0
}
],
[
{
"node": "Append Row: Candidate Sheet",
"type": "main",
"index": 0
}
]
]
},
"Slack: Get File Info": {
"main": [
[
{
"node": "Download File: From Slack",
"type": "main",
"index": 0
}
]
]
},
"Read: Candidate Sheet": {
"main": [
[
{
"node": "Check: Duplicate?",
"type": "main",
"index": 0
}
]
]
},
"Download File: From Slack": {
"main": [
[
{
"node": "easybits Extract: LinkedIn Profile",
"type": "main",
"index": 0
}
]
]
},
"Append Row: Candidate Sheet": {
"main": [
[
{
"node": "Post Confirmation: Slack (via API)",
"type": "main",
"index": 0
}
]
]
},
"Normalize: Candidate Fields": {
"main": [
[
{
"node": "Read: Candidate Sheet",
"type": "main",
"index": 0
}
]
]
},
"On File Shared: Slack Channel": {
"main": [
[
{
"node": "Slack: Get File Info",
"type": "main",
"index": 0
}
]
]
},
"easybits Extract: LinkedIn Profile": {
"main": [
[
{
"node": "Normalize: Candidate Fields",
"type": "main",
"index": 0
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
easybitsExtractorApigoogleSheetsOAuth2ApislackApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow watches a Slack channel for shared LinkedIn profile screenshots or PDFs, uses easybits Extractor to convert the file into structured candidate fields, deduplicates against a Google Sheets candidate database, and replies in the original Slack thread with either a…
Source: https://n8n.io/workflows/16100/ — 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.
Nova AI Content Marketing Agent - LinkedIn & Facebook Automation This n8n template demonstrates how to create a complete AI-powered social media content creation and scheduling system that generates p
Disclaimer: this workflow only works on self-hosted instances due to the file system usage.
More workflow: https://aitool.wiki/
> ⚠️ Disclaimer: This workflow uses Community Nodes and requires a self-hosted n8n instance.
This template is ideal for sales teams, recruiters, business development professionals, and relationship managers who need to monitor changes in their network's LinkedIn profiles. Perfect for agencies