This workflow corresponds to n8n.io template #14814 — we link there as the canonical source.
This workflow follows the HTTP Request → WhatsApp 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 →
{
"nodes": [
{
"id": "30659302-22f3-4470-bf2f-2d8c80ae16bd",
"name": "Main Sticky",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1872,
64
],
"parameters": {
"color": 2,
"width": 500,
"height": 456,
"content": "## WhatsApp Career Ranking Bot\nAnalyze resumes via WhatsApp to provide career scores, ATS feedback, and ranking insights.\n\n\n### How it works\n1. User triggers bot via keyword.\n\n2. Collects name, target role, and PDF resume.\n\n3. Scoring Engine calculates metrics.\n\n4. AI identifies rejection reasons.\n\n5. Returns personalized report to user."
},
"typeVersion": 1
},
{
"id": "d89d7440-ad39-4ec5-aff8-2b9ba2dd72da",
"name": "WhatsApp Trigger",
"type": "n8n-nodes-base.whatsAppTrigger",
"position": [
-1328,
256
],
"parameters": {
"options": {},
"updates": [
"messages"
]
},
"typeVersion": 1
},
{
"id": "41605855-506a-4e80-a14c-be618903af45",
"name": "Conversation State Manager",
"type": "n8n-nodes-base.code",
"position": [
-1104,
256
],
"parameters": {
"jsCode": "// ============================================\n// CONVERSATION STATE MANAGER \u2014 PRODUCTION\n// All fields always saved unconditionally\n// ============================================\n\nconst SUPABASE_URL = 'https://zfyyiewkkjyzxnyiabvk.supabase.co';\nconst SUPABASE_KEY = 'eyJ_YOUR_JWT_TOKEN_HERE';\n\nconst readHeaders = {\n 'apikey': SUPABASE_KEY,\n 'Authorization': `Bearer ${SUPABASE_KEY}`,\n 'Content-Type': 'application/json'\n};\n\nconst upsertHeaders = {\n 'apikey': SUPABASE_KEY,\n 'Authorization': `Bearer ${SUPABASE_KEY}`,\n 'Content-Type': 'application/json',\n 'Prefer': 'resolution=merge-duplicates,return=minimal'\n};\n\nconst message = $input.first().json;\n\nif (!message.messages || !message.messages[0]) {\n return [{ json: { action: 'IGNORE' } }];\n}\n\nconst msg = message.messages[0];\n\nif (msg.type === 'status' || (!msg.text && !msg.document)) {\n return [{ json: { action: 'IGNORE' } }];\n}\n\nconst from = msg.from;\nconst hasText = !!msg.text;\nconst hasDocument = !!msg.document;\nconst userText = hasText ? msg.text.body.trim() : '';\nconst upperText = userText.toUpperCase().replace(/[^A-Z0-9 ]/g, '').trim();\n\n// Extract WhatsApp profile name from contacts array\nlet profileName = '';\ntry {\n if (message.contacts && message.contacts[0] && message.contacts[0].profile) {\n profileName = message.contacts[0].profile.name || '';\n }\n} catch(e) { /* no contact info available */ }\n\n// \u2500\u2500 LOAD SESSION FROM SUPABASE \u2500\u2500\nlet session = { state: 'IDLE', name: '', target_role: '', started_at: new Date().toISOString() };\n\ntry {\n const rows = await this.helpers.httpRequest({\n method: 'GET',\n url: `${SUPABASE_URL}/rest/v1/whatsapp_sessions?phone=eq.${encodeURIComponent(from)}&select=*`,\n headers: readHeaders,\n json: true\n });\n if (Array.isArray(rows) && rows.length > 0) {\n session = rows[0];\n session.name = session.name || profileName || '';\n session.target_role = session.target_role || '';\n }\n} catch(e) {\n console.error('Supabase read error:', e.message);\n}\n\n// Always update name from WhatsApp profile if not yet saved\nif (!session.name && profileName) {\n session.name = profileName;\n}\n\nlet action = 'IGNORE';\nlet replyText = '';\n\n// \u2500\u2500 STATE MACHINE \u2500\u2500\nswitch (session.state) {\n\n case 'IDLE':\n if (\n upperText === 'CHECK MY RANK' ||\n upperText === 'CHECK' ||\n upperText === 'CHECKMYRANK' ||\n upperText === 'START' ||\n upperText === 'RANK'\n ) {\n action = 'SEND_INTRO';\n session.state = 'WAITING_YES';\n session.started_at = new Date().toISOString();\n replyText = `*Career Ranking Engine* \ud83d\udc4b\\n\\nI'll analyze your resume and show you:\\n\\n\ud83d\udcca Your *career score* (0-100)\\n\u274c *Why you're getting rejected*\\n\u2705 *What to fix* to get more interviews\\n\\nThis takes about 60 seconds.\\n\\n\ud83d\udc49 *Type YES to start*`;\n } else {\n action = 'SEND_HELP';\n session.started_at = new Date().toISOString();\n replyText = `\ud83d\udc4b Want to know where you stand in the job market?\\n\\n\ud83d\udcf2 Type *CHECK MY RANK* to get started!`;\n }\n break;\n\n case 'WAITING_YES':\n if (\n upperText === 'YES' ||\n upperText === 'Y' ||\n upperText === 'YEAH' ||\n upperText === 'YEP' ||\n upperText === 'SURE' ||\n upperText === 'OK' ||\n upperText === 'OKAY'\n ) {\n action = 'ASK_NAME';\n session.state = 'WAITING_NAME';\n replyText = `Great! \ud83d\ude80\\n\\n*Step 1 of 3*\\n\\nWhat's your *full name*?\\n\\n_Example: Rahul Sharma_`;\n } else if (upperText === 'NO' || upperText === 'N' || upperText === 'CANCEL') {\n action = 'SEND_REPLY';\n session.state = 'IDLE';\n replyText = `No worries! Whenever you're ready, type *CHECK MY RANK* \ud83d\udcaa`;\n } else if (\n upperText === 'CHECK MY RANK' ||\n upperText === 'CHECK' ||\n upperText === 'START'\n ) {\n action = 'SEND_INTRO';\n session.state = 'WAITING_YES';\n session.started_at = new Date().toISOString();\n replyText = `*Career Ranking Engine* \ud83d\udc4b\\n\\nI'll analyze your resume and show you:\\n\\n\ud83d\udcca Your *career score* (0-100)\\n\u274c *Why you're getting rejected*\\n\u2705 *What to fix* to get more interviews\\n\\nThis takes about 60 seconds.\\n\\n\ud83d\udc49 *Type YES to start*`;\n } else {\n action = 'SEND_REPLY';\n replyText = `Please type *YES* to start or *NO* to cancel.`;\n }\n break;\n\n case 'WAITING_NAME':\n if (hasText && userText.length >= 2 && userText.length <= 100) {\n action = 'ASK_ROLE';\n session.name = userText;\n session.state = 'WAITING_ROLE';\n replyText = `Nice to meet you, *${session.name}*! \ud83d\udc4b\\n\\n*Step 2 of 3*\\n\\nWhich *job role* are you targeting?\\n\\n_Example: Data Analyst, Frontend Developer, Product Manager_`;\n } else {\n action = 'SEND_REPLY';\n replyText = `Please enter your full name (e.g. \"Rahul Sharma\"):`;\n }\n break;\n\n case 'WAITING_ROLE':\n if (hasText && userText.length >= 2 && userText.length <= 150) {\n action = 'ASK_RESUME';\n session.target_role = userText;\n session.state = 'WAITING_RESUME';\n replyText = `Got it \u2014 *${session.target_role}* \ud83c\udfaf\\n\\n*Step 3 of 3*\\n\\nUpload your *resume as a PDF* \ud83d\udcce\\n\\n_(Tap \ud83d\udcce and attach your PDF)_`;\n } else {\n action = 'SEND_REPLY';\n replyText = `Please enter a valid job role (e.g. \"Data Analyst\", \"Software Engineer\"):`;\n }\n break;\n\n case 'WAITING_RESUME':\n if (hasDocument) {\n if (msg.document.mime_type === 'application/pdf') {\n action = 'PROCESS_RESUME';\n session.state = 'PROCESSING';\n replyText = `\ud83d\udcc4 Resume received!\\n\\n\u23f3 *Analyzing your profile for ${session.target_role}...*\\n\\nHang tight, this takes 15-30 seconds! \ud83d\udd0d`;\n } else {\n action = 'SEND_REPLY';\n replyText = `\u26a0\ufe0f Please upload a *PDF file* only. DOC/DOCX not supported.`;\n }\n } else {\n action = 'SEND_REPLY';\n replyText = `Please tap \ud83d\udcce and attach your resume as a *PDF file*.`;\n }\n break;\n\n case 'PROCESSING':\n action = 'SEND_REPLY';\n replyText = `\u23f3 Still analyzing... please wait a moment!`;\n break;\n\n default:\n session.state = 'IDLE';\n session.started_at = new Date().toISOString();\n action = 'SEND_HELP';\n replyText = `Type *CHECK MY RANK* to get started!`;\n}\n\n// \u2500\u2500 SAVE SESSION \u2014 always send ALL fields \u2500\u2500\ntry {\n await this.helpers.httpRequest({\n method: 'POST',\n url: `${SUPABASE_URL}/rest/v1/whatsapp_sessions?on_conflict=phone`,\n headers: upsertHeaders,\n body: {\n phone: from,\n state: session.state,\n name: session.name || '',\n target_role: session.target_role || '',\n started_at: session.started_at || new Date().toISOString(),\n updated_at: new Date().toISOString()\n },\n json: true\n });\n} catch(e) {\n console.error('Supabase save error:', e.message);\n}\n\nreturn [{\n json: {\n action,\n replyText,\n from,\n session,\n documentId: hasDocument ? msg.document.id : null,\n messageType: hasDocument ? 'document' : 'text'\n }\n}];"
},
"typeVersion": 2
},
{
"id": "a0388610-aa7a-4006-9b5f-ed2ec01b3152",
"name": "Route Action",
"type": "n8n-nodes-base.switch",
"position": [
-880,
256
],
"parameters": {
"rules": {
"values": [
{
"outputKey": "Process Resume",
"conditions": {
"options": {
"version": 2,
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "route-process",
"operator": {
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.action }}",
"rightValue": "PROCESS_RESUME"
}
]
},
"renameOutput": true
},
{
"outputKey": "Send Reply",
"conditions": {
"options": {
"version": 2,
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "route-reply",
"operator": {
"type": "string",
"operation": "notEquals"
},
"leftValue": "={{ $json.action }}",
"rightValue": "PROCESS_RESUME"
},
{
"id": "route-not-ignore",
"operator": {
"type": "string",
"operation": "notEquals"
},
"leftValue": "={{ $json.action }}",
"rightValue": "IGNORE"
}
]
},
"renameOutput": true
}
]
},
"options": {
"fallbackOutput": "none"
}
},
"typeVersion": 3.2
},
{
"id": "dc85e1fc-1209-4e3c-beb6-94a6b7eed92e",
"name": "Send Conversation Reply",
"type": "n8n-nodes-base.whatsApp",
"position": [
-656,
352
],
"parameters": {
"textBody": "={{ $json.replyText }}",
"operation": "send",
"phoneNumberId": "YOUR_PHONE_NUMBER_HERE",
"additionalFields": {},
"recipientPhoneNumber": "={{ $json.from }}"
},
"typeVersion": 1
},
{
"id": "43c95c4c-b314-480b-b3a9-3d0a9afc2a1a",
"name": "Send Processing Message",
"type": "n8n-nodes-base.whatsApp",
"position": [
-656,
160
],
"parameters": {
"textBody": "={{ $json.replyText }}",
"operation": "send",
"phoneNumberId": "YOUR_PHONE_NUMBER_HERE",
"additionalFields": {},
"recipientPhoneNumber": "={{ $json.from }}"
},
"typeVersion": 1
},
{
"id": "101eea2c-b3da-4142-8aac-55298f7d81de",
"name": "Get Resume URL",
"type": "n8n-nodes-base.whatsApp",
"position": [
-432,
160
],
"parameters": {
"resource": "media",
"operation": "mediaUrlGet",
"mediaGetId": "={{ $('Conversation State Manager').item.json.documentId }}"
},
"typeVersion": 1
},
{
"id": "cb5b5d8f-449b-48e3-a41e-c9f1ca9a9b69",
"name": "Download Resume",
"type": "n8n-nodes-base.httpRequest",
"position": [
-208,
160
],
"parameters": {
"url": "={{ $json.url }}",
"options": {
"response": {
"response": {
"responseFormat": "file"
}
}
},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "whatsAppApi"
},
"typeVersion": 4.2
},
{
"id": "a66ac75a-6a17-47c2-b588-e863c12648b8",
"name": "Extract Resume Text",
"type": "n8n-nodes-base.extractFromFile",
"position": [
64,
160
],
"parameters": {
"options": {},
"operation": "pdf"
},
"typeVersion": 1
},
{
"id": "638a5b48-bc0c-4c47-a0f1-79726cb1061f",
"name": "Set Resume Data",
"type": "n8n-nodes-base.set",
"position": [
288,
160
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "set-resume-text",
"name": "resume_text",
"type": "string",
"value": "={{ $json.text }}"
},
{
"id": "set-target-role",
"name": "target_role",
"type": "string",
"value": "={{ $('Conversation State Manager').item.json.session.target_role }}"
},
{
"id": "set-user-from",
"name": "from",
"type": "string",
"value": "={{ $('Conversation State Manager').item.json.from }}"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "cff64e41-74ec-4491-93df-152b5bdd4de6",
"name": "Scoring Engine",
"type": "n8n-nodes-base.code",
"position": [
624,
160
],
"parameters": {
"jsCode": "// ============================================\n// REAL SCORING ENGINE \u2014 5 Dimensions = 100pts\n// Impact(25) + Relevance(25) + ATS(20) + Clarity(15) + Completeness(15)\n// NO caps. NO random. NO fake data.\n// ============================================\n\nconst SUPABASE_URL = 'https://zfyyiewkkjyzxnyiabvk.supabase.co';\nconst SUPABASE_KEY = 'eyJ_YOUR_JWT_TOKEN_HERE';\n\nconst readHeaders = {\n 'apikey': SUPABASE_KEY,\n 'Authorization': `Bearer ${SUPABASE_KEY}`,\n 'Content-Type': 'application/json'\n};\nconst upsertHeaders = { ...readHeaders, 'Prefer': 'resolution=merge-duplicates,return=minimal' };\n\nconst resumeText = $input.first().json.resume_text || '';\nconst targetRole = ($input.first().json.target_role || '').toLowerCase().trim();\nconst from = $input.first().json.from || '';\nconst text = resumeText.toLowerCase();\nconst words = text.split(/\\s+/).filter(w => w.length > 0);\nconst wordCount = words.length;\n\n// 1. IMPACT SCORE (0-25)\nlet impactScore = 0;\nconst impactVerbs = [\n 'led','built','managed','created','developed','launched','grew','increased',\n 'reduced','improved','achieved','implemented','delivered','designed','scaled',\n 'optimized','automated','generated','drove','spearheaded','founded','pioneered',\n 'established','transformed','streamlined','negotiated','secured','acquired',\n 'deployed','migrated','published','awarded','trained','mentored','directed',\n 'accelerated','boosted','cut','saved','shipped','refactored','architected'\n];\nconst uniqueVerbsUsed = [...new Set(impactVerbs.filter(v => text.includes(v)))];\nimpactScore += Math.min(uniqueVerbsUsed.length * 1.3, 12);\nconst quantPattern = /\\d+[%+xk]|\\$\\d+|\\d+\\s*(million|billion|thousand|k\\b|users|customers|clients|revenue|growth|reduction|increase|improvement|efficiency|saving|profit|leads|conversions|downloads)/gi;\nconst quantMatches = (resumeText.match(quantPattern) || []).length;\nimpactScore += Math.min(quantMatches * 2.5, 13);\nimpactScore = Math.min(Math.round(impactScore), 25);\n\n// 2. RELEVANCE SCORE (0-25)\nlet relevanceScore = 0;\nconst roleKeywordsMap = {\n 'data analyst': ['sql','python','excel','tableau','power bi','data','analytics','statistics','visualization','dashboard','reporting','pandas','numpy','etl','bigquery','snowflake','looker'],\n 'software engineer': ['javascript','python','java','react','node','api','git','aws','docker','agile','rest','sql','typescript','ci/cd','microservices','spring','kafka'],\n 'frontend developer': ['html','css','javascript','react','vue','angular','typescript','responsive','ui','ux','figma','tailwind','next.js','sass','webpack','vite','redux'],\n 'backend developer': ['python','java','node','api','database','sql','nosql','aws','docker','microservices','rest','graphql','redis','kafka','postgresql','mongodb','spring'],\n 'full stack developer': ['javascript','react','node','python','sql','api','git','docker','aws','typescript','mongodb','postgresql','graphql','rest'],\n 'product manager': ['product','roadmap','stakeholder','agile','scrum','user research','a/b testing','kpi','metrics','strategy','prioritization','jira','okr','gtm','sprint','analytics'],\n 'marketing manager': ['marketing','campaign','seo','social media','content','brand','analytics','roi','digital','engagement','conversion','google ads','email marketing','crm','hubspot'],\n 'ux designer': ['ux','ui','figma','sketch','wireframe','prototype','user research','usability','design thinking','interaction design','accessibility','user testing','adobe xd'],\n 'devops engineer': ['docker','kubernetes','ci/cd','aws','terraform','jenkins','ansible','linux','monitoring','pipeline','cloud','infrastructure','helm','prometheus','grafana'],\n 'machine learning engineer': ['python','tensorflow','pytorch','ml','deep learning','neural network','nlp','model','training','scikit-learn','feature engineering','mlops','llm','huggingface'],\n 'data scientist': ['python','r','machine learning','statistics','pandas','scikit-learn','tensorflow','sql','visualization','hypothesis','regression','classification','clustering','nlp'],\n 'business analyst': ['business analysis','requirements','stakeholder','sql','process','documentation','jira','agile','reporting','data','workflow','use case','gap analysis'],\n 'cloud engineer': ['aws','azure','gcp','terraform','kubernetes','docker','ci/cd','s3','ec2','lambda','cloudformation','iam','vpc'],\n 'android developer': ['android','kotlin','java','xml','jetpack','firebase','mvvm','retrofit','room','gradle','material design','coroutines'],\n 'ios developer': ['ios','swift','xcode','uikit','swiftui','firebase','alamofire','mvvm','core data','objective-c'],\n 'project manager': ['project management','pmp','agile','scrum','waterfall','budget','stakeholder','risk','timeline','deliverables','jira','ms project','gantt'],\n 'cybersecurity': ['security','penetration testing','firewall','siem','soc','vulnerability','encryption','compliance','iso27001','owasp','incident response','threat']\n};\n\nlet matchedKeywords = [];\nlet bestRoleMatch = null;\n\nfor (const [role, keywords] of Object.entries(roleKeywordsMap)) {\n if (targetRole.includes(role) || role.includes(targetRole)) {\n matchedKeywords = keywords; bestRoleMatch = role; break;\n }\n}\nif (!bestRoleMatch) {\n let bestOverlap = 0;\n const targetWords = targetRole.split(' ');\n for (const [role, keywords] of Object.entries(roleKeywordsMap)) {\n const overlap = role.split(' ').filter(w => targetWords.some(tw => tw.includes(w) || w.includes(tw))).length;\n if (overlap > bestOverlap) { bestOverlap = overlap; matchedKeywords = keywords; bestRoleMatch = role; }\n }\n}\nif (matchedKeywords.length === 0) {\n matchedKeywords = ['experience','team','project','management','communication','leadership','skills','results','strategy','analysis'];\n}\nconst keywordHits = matchedKeywords.filter(kw => text.includes(kw)).length;\nrelevanceScore = Math.round((keywordHits / matchedKeywords.length) * 25);\n\n// 3. ATS READINESS SCORE (0-20)\nlet atsScore = 0;\nconst sections = ['experience','education','skills','summary','objective','projects','certifications','achievements','work history','accomplishments','awards','publications'];\natsScore += Math.min(sections.filter(s => text.includes(s)).length * 2, 8);\nif (/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}/.test(resumeText)) atsScore += 3;\nif (/\\+?\\d[\\d\\s\\-().]{7,}/.test(resumeText)) atsScore += 2;\nif (/linkedin\\.com\\/in\\//i.test(resumeText)) atsScore += 2;\nif (/github\\.com\\//i.test(resumeText)) atsScore += 2;\nconst specialDensity = (resumeText.match(/[^\\x00-\\x7F]/g) || []).length / Math.max(resumeText.length, 1);\nif (specialDensity < 0.005) atsScore += 3; else if (specialDensity < 0.02) atsScore += 1;\natsScore = Math.min(atsScore, 20);\n\n// 4. CLARITY SCORE (0-15)\nlet clarityScore = 0;\nconst sentences = resumeText.split(/[.!?]+/).filter(s => s.trim().length > 5);\nconst avgSentLen = sentences.length > 0 ? wordCount / sentences.length : 0;\nif (avgSentLen >= 8 && avgSentLen <= 20) clarityScore += 5; else if (avgSentLen > 0) clarityScore += 2;\nconst bulletCount = (resumeText.match(/[\u2022\\-\\*]/g) || []).length;\nif (bulletCount >= 10) clarityScore += 5; else if (bulletCount >= 5) clarityScore += 3; else if (bulletCount >= 2) clarityScore += 1;\nconst yearRanges = (resumeText.match(/20\\d{2}\\s*[-to]+\\s*(20\\d{2}|present|current)/gi) || []).length;\nconst yearMentions = (resumeText.match(/20[0-2]\\d/g) || []).length;\nif (yearRanges >= 2) clarityScore += 5; else if (yearMentions >= 3) clarityScore += 3; else if (yearMentions >= 1) clarityScore += 1;\nclarityScore = Math.min(clarityScore, 15);\n\n// 5. COMPLETENESS SCORE (0-15)\nlet completenessScore = 0;\nif (wordCount >= 300 && wordCount <= 700) completenessScore += 6;\nelse if (wordCount >= 200 && wordCount <= 900) completenessScore += 4;\nelse if (wordCount >= 100) completenessScore += 2;\nif (/bachelor|master|phd|b\\.sc|m\\.sc|b\\.tech|m\\.tech|bca|mca|degree|university|college/i.test(resumeText)) completenessScore += 4;\nif (/certif|certified|aws certified|google cloud|azure|pmp|cpa|cfa|cissp|comptia|coursera|udemy/i.test(resumeText)) completenessScore += 3;\nif (/project/i.test(resumeText)) completenessScore += 2;\ncompletenessScore = Math.min(completenessScore, 15);\n\n// FINAL SCORE \u2014 true 0-100, no cap, no randomness\nconst finalScore = impactScore + relevanceScore + atsScore + clarityScore + completenessScore;\nconst normalizedRole = bestRoleMatch || targetRole;\n\n// SAVE SCORE TO SUPABASE\ntry {\n await this.helpers.httpRequest({\n method: 'POST',\n url: `${SUPABASE_URL}/rest/v1/resume_scores?on_conflict=phone,target_role`,\n headers: upsertHeaders,\n body: { phone: from, target_role: normalizedRole, score: finalScore, scored_at: new Date().toISOString() },\n json: true\n });\n} catch(e) { console.error('Score save error:', e.message); }\n\n// FETCH REAL CANDIDATE STATS FROM SUPABASE\nlet totalCandidates = 1, rank = 1, percentile = 'First submission!', percentileLabel = '\ud83c\udf1f';\ntry {\n const allScores = await this.helpers.httpRequest({\n method: 'GET',\n url: `${SUPABASE_URL}/rest/v1/resume_scores?target_role=eq.${encodeURIComponent(normalizedRole)}&select=score&order=score.desc`,\n headers: readHeaders,\n json: true\n });\n if (Array.isArray(allScores) && allScores.length > 1) {\n totalCandidates = allScores.length;\n const sorted = allScores.map(r => r.score).sort((a, b) => b - a);\n rank = sorted.findIndex(s => s <= finalScore) + 1;\n if (rank <= 0) rank = totalCandidates;\n const belowCount = allScores.filter(r => r.score < finalScore).length;\n const pct = Math.round((belowCount / totalCandidates) * 100);\n if (pct >= 85) { percentile = 'Top 15%'; percentileLabel = '\ud83d\udfe2'; }\n else if (pct >= 70) { percentile = 'Top 30%'; percentileLabel = '\ud83d\udfe1'; }\n else if (pct >= 50) { percentile = 'Top 50%'; percentileLabel = '\ud83d\udfe0'; }\n else if (pct >= 35) { percentile = 'Top 65%'; percentileLabel = '\ud83d\udd34'; }\n else { percentile = 'Bottom 35%'; percentileLabel = '\ud83d\udd34'; }\n }\n} catch(e) { console.error('Stats fetch error:', e.message); }\n\n// INTERVIEW PROBABILITY \u2014 honest, can reach High\nlet probability;\nif (finalScore >= 80) probability = 'High';\nelse if (finalScore >= 65) probability = 'Moderate';\nelse if (finalScore >= 50) probability = 'Low';\nelse probability = 'Very Low';\n\n// EXTRACT EMAIL FROM RESUME\nlet extractedEmail = '';\nconst emailMatch = resumeText.match(/[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}/);\nif (emailMatch) extractedEmail = emailMatch[0];\n\n// EXTRACT LINKEDIN URL FROM RESUME\nlet extractedLinkedin = '';\nconst linkedinMatch = resumeText.match(/(?:https?:\\/\\/)?(?:www\\.)?linkedin\\.com\\/in\\/[a-zA-Z0-9_\\-]+\\/?/i);\nif (linkedinMatch) {\n extractedLinkedin = linkedinMatch[0];\n if (!extractedLinkedin.startsWith('http')) extractedLinkedin = 'https://' + extractedLinkedin;\n}\n\n// SAVE EMAIL, LINKEDIN, RESUME TEXT TO WHATSAPP_SESSIONS\ntry {\n await this.helpers.httpRequest({\n method: 'PATCH',\n url: `${SUPABASE_URL}/rest/v1/whatsapp_sessions?phone=eq.${encodeURIComponent(from)}`,\n headers: { ...readHeaders, 'Prefer': 'return=minimal' },\n body: {\n email: extractedEmail,\n linkedin_url: extractedLinkedin,\n resume_url: ''\n },\n json: true\n });\n} catch(e) { console.error('Email/LinkedIn/Resume save error:', e.message); }\n\nreturn [{\n json: {\n finalScore, percentile, percentileLabel, rank, totalCandidates, probability,\n impactScore, relevanceScore, atsScore, clarityScore, completenessScore,\n bestRoleMatch: normalizedRole, wordCount, extractedEmail, extractedLinkedin,\n targetRole: $input.first().json.target_role, from, resume_text: resumeText\n }\n}];"
},
"typeVersion": 2
},
{
"id": "9d78444a-c2bc-4e3c-8375-cc69a4576c6a",
"name": "OpenAI - Rejection Analysis",
"type": "n8n-nodes-base.httpRequest",
"position": [
512,
256
],
"parameters": {
"url": "YOUR_API_ENDPOINT_HERE",
"method": "POST",
"options": {},
"jsonBody": "={{ {\n \"model\": \"gpt-4o-mini\",\n \"max_tokens\": 800,\n \"response_format\": { \"type\": \"json_object\" },\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are an expert ATS resume reviewer. Return ONLY valid JSON, no extra text.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Analyze this resume for the role of \" + $json.target_role + \".\\n\\nResume:\\n\" + $json.resume_text.substring(0, 3000) + \"\\n\\nReturn ONLY this JSON:\\n{\\n \\\"rejection_reasons\\\": [\\n { \\\"reason\\\": \\\"specific reason\\\" },\\n { \\\"reason\\\": \\\"second reason\\\" },\\n { \\\"reason\\\": \\\"third reason\\\" }\\n ],\\n \\\"fixes\\\": [\\n { \\\"fix\\\": \\\"specific fix\\\" },\\n { \\\"fix\\\": \\\"second fix\\\" },\\n { \\\"fix\\\": \\\"third fix\\\" }\\n ],\\n \\\"one_liner\\\": \\\"one sentence summary of biggest weakness\\\"\\n}\"\n }\n ]\n} }}",
"sendBody": true,
"specifyBody": "json",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "openAiApi"
},
"typeVersion": 4.2
},
{
"id": "1f4dda0f-9866-4f94-8a65-cfd27aa081e6",
"name": "Parse AI Response",
"type": "n8n-nodes-base.set",
"position": [
736,
256
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "parse-ai",
"name": "ai_analysis",
"type": "object",
"value": "={{ JSON.parse($json.choices[0].message.content) }}"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "6eab4bb5-1fbc-4e56-b146-55054ec3cca8",
"name": "Merge Score + AI",
"type": "n8n-nodes-base.merge",
"position": [
928,
176
],
"parameters": {
"mode": "combine",
"options": {},
"combineBy": "combineAll"
},
"typeVersion": 3
},
{
"id": "ce82879b-2bce-44c9-87d6-a66aca4b609e",
"name": "Format Result Message",
"type": "n8n-nodes-base.code",
"position": [
1120,
176
],
"parameters": {
"jsCode": "// FORMAT FINAL MESSAGE \u2014 PRODUCTION\n// No mock/fallback results. No share footer. No name.\n\nconst SUPABASE_URL = 'https://zfyyiewkkjyzxnyiabvk.supabase.co';\nconst SUPABASE_KEY = 'eyJ_YOUR_JWT_TOKEN_HERE';\n\nconst item = $input.first().json;\n\nconst score = item.finalScore;\nconst percentile = item.percentile;\nconst percentileLabel = item.percentileLabel;\nconst rank = item.rank;\nconst total = item.totalCandidates;\nconst probability = item.probability;\nconst targetRole = item.targetRole;\nconst ai = item.ai_analysis;\n\n// Score bar\nconst filled = Math.round(score / 10);\nconst empty = 10 - filled;\nconst scoreBar = '\u2588'.repeat(filled) + '\u2591'.repeat(empty);\n\n// Rejection reasons \u2014 real AI output only, no fallback\nlet rejectionBlock = '';\nai.rejection_reasons.slice(0, 3).forEach((r, i) => {\n rejectionBlock += `${i + 1}. ${r.reason}\\n`;\n});\n\n// Fixes \u2014 real AI output only, no fallback\nlet fixesBlock = '';\nai.fixes.slice(0, 3).forEach((f, i) => {\n fixesBlock += `${i + 1}. ${f.fix}\\n`;\n});\n\nconst oneLiner = ai.one_liner;\n\nconst message = `*CAREER RANK REPORT*\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n\n\ud83c\udfaf Role: *${targetRole}*\n\n\ud83d\udcca *Your Score: ${score}/100*\n${scoreBar}\n\n\ud83d\udcc8 Percentile: ${percentileLabel} *${percentile}*\n\ud83c\udfc5 Rank: *#${rank}* out of ${total}+ candidates\n\u26a1 Interview Probability: *${probability}*\n\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n\u274c *WHY YOU'RE GETTING REJECTED:*\n\n${rejectionBlock}\n\ud83d\udca1 _${oneLiner}_\n\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n\u2705 *WHAT TO FIX:*\n\n${fixesBlock}\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n\ud83d\ude80 *Want to improve your rank?*\n\nGet personalized job recommendations \ud83d\udc47\n\ud83d\udd17 https://meracareer.io`;\n\n// Reset session to IDLE \u2014 preserve name, email, linkedin, resume\ntry {\n await this.helpers.httpRequest({\n method: 'PATCH',\n url: `${SUPABASE_URL}/rest/v1/whatsapp_sessions?phone=eq.${encodeURIComponent(item.from)}`,\n headers: {\n 'apikey': SUPABASE_KEY,\n 'Authorization': `Bearer ${SUPABASE_KEY}`,\n 'Content-Type': 'application/json',\n 'Prefer': 'return=minimal'\n },\n body: {\n state: 'IDLE',\n updated_at: new Date().toISOString()\n },\n json: true\n });\n} catch(e) {\n console.error('Session reset error:', e.message);\n}\n\nreturn [{\n json: {\n from: item.from,\n message: message\n }\n}];"
},
"typeVersion": 2
},
{
"id": "f6681ffc-734e-42fc-8a3c-e74bbbd49f8c",
"name": "Send Final Result",
"type": "n8n-nodes-base.whatsApp",
"position": [
1312,
176
],
"parameters": {
"textBody": "={{ $json.message }}",
"operation": "send",
"phoneNumberId": "YOUR_PHONE_NUMBER_HERE",
"additionalFields": {},
"recipientPhoneNumber": "={{ $json.from }}"
},
"typeVersion": 1
},
{
"id": "f7585811-4a59-4840-aa9d-af2e81cda42a",
"name": "Upload Resume to Storage",
"type": "n8n-nodes-base.code",
"position": [
64,
336
],
"parameters": {
"jsCode": "// Upload PDF resume to Supabase Storage and save URL to DB\nconst SUPABASE_URL = 'https://zfyyiewkkjyzxnyiabvk.supabase.co';\nconst SUPABASE_KEY = 'eyJ_YOUR_JWT_TOKEN_HERE';\n\n// Get phone from Conversation State Manager\nconst phone = $('Conversation State Manager').item.json.from;\nconst safePhone = phone.replace(/[^a-zA-Z0-9]/g, '_');\nconst fileName = `${safePhone}_${Date.now()}.pdf`;\n\nlet resumeUrl = '$input.first().json.url';\n\ntry {\n // \u2500\u2500 Find the binary field dynamically \u2500\u2500\n // n8n may name the field 'data', 'file', or something else depending on the HTTP node config\n const allBinary = items[0].binary || {};\n const binaryKey = Object.keys(allBinary)[0]; // grab the first (and only) binary field\n\n console.log('Available binary keys:', JSON.stringify(Object.keys(allBinary)));\n\n if (!binaryKey) {\n // No binary in items \u2014 try reading from $input\n const inputItem = $input.first();\n const inputBinary = inputItem.binary || {};\n const inputKey = Object.keys(inputBinary)[0];\n console.log('Input binary keys:', JSON.stringify(Object.keys(inputBinary)));\n\n if (!inputKey) {\n console.log('ERROR: No binary data found anywhere. Skipping upload.');\n return [{ json: { resume_url: '', phone, error: 'no_binary' } }];\n }\n }\n\n // Use n8n's native getBinaryDataBuffer \u2014 this is the CORRECT way to read binary in Code nodes\n const buffer = await this.helpers.getBinaryDataBuffer(0, binaryKey || 'data');\n console.log('Buffer size:', buffer.length, 'bytes');\n\n if (!buffer || buffer.length === 0) {\n console.log('ERROR: Buffer is empty. Skipping upload.');\n return [{ json: { resume_url: '', phone, error: 'empty_buffer' } }];\n }\n\n // Upload raw PDF buffer to Supabase Storage\n const uploadResponse = await this.helpers.httpRequest({\n method: 'POST',\n url: `${SUPABASE_URL}/storage/v1/object/resumes/${encodeURIComponent(fileName)}`,\n headers: {\n 'apikey': SUPABASE_KEY,\n 'Authorization': `Bearer ${SUPABASE_KEY}`,\n 'Content-Type': 'application/pdf',\n 'x-upsert': 'true'\n },\n body: buffer\n });\n\n console.log('Upload response:', JSON.stringify(uploadResponse));\n\n // Supabase Storage returns { Key: 'resumes/filename.pdf' } on success\n if (uploadResponse && (uploadResponse.Key || uploadResponse.Id || uploadResponse.id)) {\n resumeUrl = `${SUPABASE_URL}/storage/v1/object/public/resumes/${encodeURIComponent(fileName)}`;\n console.log('Resume uploaded successfully:', resumeUrl);\n\n // Save resume URL to whatsapp_sessions table\n await this.helpers.httpRequest({\n method: 'PATCH',\n url: `${SUPABASE_URL}/rest/v1/whatsapp_sessions?phone=eq.${encodeURIComponent(phone)}`,\n headers: {\n 'apikey': SUPABASE_KEY,\n 'Authorization': `Bearer ${SUPABASE_KEY}`,\n 'Content-Type': 'application/json',\n 'Prefer': 'return=minimal'\n },\n body: { resume_url: resumeUrl },\n json: true\n });\n console.log('Resume URL saved to DB:', resumeUrl);\n } else {\n console.error('Upload may have failed. Response was:', JSON.stringify(uploadResponse));\n // Still try to build the URL \u2014 Supabase sometimes returns 200 with empty body on upsert\n resumeUrl = `${SUPABASE_URL}/storage/v1/object/public/resumes/${encodeURIComponent(fileName)}`;\n\n await this.helpers.httpRequest({\n method: 'PATCH',\n url: `${SUPABASE_URL}/rest/v1/whatsapp_sessions?phone=eq.${encodeURIComponent(phone)}`,\n headers: {\n 'apikey': SUPABASE_KEY,\n 'Authorization': `Bearer ${SUPABASE_KEY}`,\n 'Content-Type': 'application/json',\n 'Prefer': 'return=minimal'\n },\n body: { resume_url: resumeUrl },\n json: true\n });\n console.log('Resume URL saved (optimistic):', resumeUrl);\n }\n} catch(e) {\n console.error('Upload error:', e.message, e.stack);\n}\n\nreturn [{ json: { resume_url: resumeUrl, phone } }];"
},
"typeVersion": 2
},
{
"id": "e5a7b49f-21ef-483f-853f-f92c0b402ce5",
"name": "Section 1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1344,
64
],
"parameters": {
"color": 7,
"width": 1276,
"height": 456,
"content": "## 1. Trigger & State\nHandles WhatsApp webhook and manages user conversation progress in Supabase."
},
"typeVersion": 1
},
{
"id": "3b52f41f-325a-49aa-b26a-ad68bf7da034",
"name": "Section 2",
"type": "n8n-nodes-base.stickyNote",
"position": [
0,
64
],
"parameters": {
"color": 7,
"width": 1516,
"height": 456,
"content": "## 2. Processing & Analysis\nDownloads resume, extracts text, performs scoring, and generates AI improvement suggestions."
},
"typeVersion": 1
}
],
"connections": {
"Route Action": {
"main": [
[
{
"node": "Send Processing Message",
"type": "main",
"index": 0
}
],
[
{
"node": "Send Conversation Reply",
"type": "main",
"index": 0
}
]
]
},
"Get Resume URL": {
"main": [
[
{
"node": "Download Resume",
"type": "main",
"index": 0
}
]
]
},
"Scoring Engine": {
"main": [
[
{
"node": "Merge Score + AI",
"type": "main",
"index": 0
}
]
]
},
"Download Resume": {
"main": [
[
{
"node": "Extract Resume Text",
"type": "main",
"index": 0
},
{
"node": "Upload Resume to Storage",
"type": "main",
"index": 0
}
]
]
},
"Set Resume Data": {
"main": [
[
{
"node": "Scoring Engine",
"type": "main",
"index": 0
},
{
"node": "OpenAI - Rejection Analysis",
"type": "main",
"index": 0
}
]
]
},
"Merge Score + AI": {
"main": [
[
{
"node": "Format Result Message",
"type": "main",
"index": 0
}
]
]
},
"WhatsApp Trigger": {
"main": [
[
{
"node": "Conversation State Manager",
"type": "main",
"index": 0
}
]
]
},
"Parse AI Response": {
"main": [
[
{
"node": "Merge Score + AI",
"type": "main",
"index": 1
}
]
]
},
"Extract Resume Text": {
"main": [
[
{
"node": "Set Resume Data",
"type": "main",
"index": 0
}
]
]
},
"Format Result Message": {
"main": [
[
{
"node": "Send Final Result",
"type": "main",
"index": 0
}
]
]
},
"Send Processing Message": {
"main": [
[
{
"node": "Get Resume URL",
"type": "main",
"index": 0
}
]
]
},
"Conversation State Manager": {
"main": [
[
{
"node": "Route Action",
"type": "main",
"index": 0
}
]
]
},
"OpenAI - Rejection Analysis": {
"main": [
[
{
"node": "Parse AI Response",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Let job seekers check their resume strength directly on WhatsApp — no app, no sign-up, no friction. Users send a keyword, answer 2 quick questions, upload their PDF resume, and receive a personalized career score, ATS feedback, and rejection analysis in under 60 seconds.
Source: https://n8n.io/workflows/14814/ — 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.
This workflow lets you upload a tender/RFT PDF via an n8n form, extracts key fields into Google Sheets using LlamaParse, and makes the document chat-searchable by embedding section chunks with Google
Transform your WhatsApp messages into an organized journal with AI-powered transcription and media management.
Account manager-v1. Uses whatsAppTrigger, googleGemini, whatsApp, httpRequest. Event-driven trigger; 22 nodes.
Transform ordinary product photos into premium marketing visuals instantly using Gemini AI for prompt enhancement and Nano Banana AI for image generation through WhatsApp. Small business owners E-comm
Before running or deploying this workflow, you need to configure the following services and credentials: