AutomationFlowsAI & RAG › Score Whatsapp PDF Resumes with Openai Gpt-4o-mini and Supabase

Score Whatsapp PDF Resumes with Openai Gpt-4o-mini and Supabase

ByPanth1823 @panth1823 on n8n.io

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.

Event trigger★★★★☆ complexity19 nodesWhatsApp TriggerWhatsAppHTTP Request
AI & RAG Trigger: Event Nodes: 19 Complexity: ★★★★☆ Added:

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 →

Download .json
{
  "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
          }
        ]
      ]
    }
  }
}
Pro

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 →

More AI & RAG workflows → · Browse all categories →

Related workflows

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

AI & RAG

Transform your WhatsApp messages into an organized journal with AI-powered transcription and media management.

WhatsApp, HTTP Request, Google Docs +3
AI & RAG

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

WhatsApp Trigger, WhatsApp, HTTP Request +1
AI & RAG

Before running or deploying this workflow, you need to configure the following services and credentials:

Form Trigger, High Level, HTTP Request +1
AI & RAG

legal_rag_telegram_api_current_github_ready. Uses telegramTrigger, httpRequest. Event-driven trigger; 56 nodes.

Telegram Trigger, HTTP Request
AI & RAG

This n8n workflow automatically generates presentation-style "screen recording" videos with AI-generated slides and a talking head avatar overlay. You provide a topic and intention, and the workflow h

HTTP Request, N8N Nodes Veed, Google Drive +1