How I match candidates to roles with n8n
To match candidates to open roles with n8n, build a job pool on a timer from ATS boards, Adzuna and RemoteOK, tidy it into one shape and drop duplicates into Supabase. Then each morning pull the fresh jobs, remove the ones already matched, score the rest against the candidate profile with Claude, keep everything above your threshold, and email the best matches.
I built this because I was tired of reading the same job boards every day and eyeballing which postings actually fit a candidate. This is a real template I run. Here is the whole thing: why it matters, the usual ways people do it, how mine works node by node, and how to point it at your own candidate and roles.
The problem
The right role for a candidate is out there today. It is buried across dozens of boards that never talk to each other.
Open roles are scattered across company career pages, Adzuna, remote boards and a hundred ATS pages. To find the good ones for a candidate you open tab after tab, read the same listings you read yesterday, and try to hold their seniority, domain and location in your head while you skim. It burns an hour every morning, the best roles get filled before you reach them, and by the time you finish you have already forgotten which postings you saw last week.
Sourcing is the slow part of hiring. Recruiters commonly report spending around 13 hours a week per role just finding people, most of it reading the same boards by hand.
How most teams deal with it today
There are three common ways to match a candidate to roles today. Each has a catch.
Read the boards by hand
Free, and it works on day one. But it eats an hour every morning, you keep re-reading the same postings, and a tired person misses the one good role or forgets which ones they already sent.
Pay a recruiter or agency
A person who knows the market can find strong roles fast. They also cost a fee per placement, they juggle many candidates at once, and you only get their attention on the days they have time for you.
Lean on an ATS filter
An applicant tracking system can filter by title, location and a few keywords. But keyword filters miss the fit that matters, they only see roles already loaded into that one system, and they cannot read a job description and judge it the way a person would.
The fix
You own it, it costs almost nothing to run, and it scores every role the way a sharp recruiter would.
A single n8n flow does the reading for you. On a timer it gathers roles from three sources into one Supabase table. Each morning it scores every fresh role against the candidate with Claude, keeps the real matches, and emails a ranked digest. Nobody opens a job board by hand. Here is what it does, in three parts.
Builds a fresh job pool
Every three hours it gathers postings from curated ATS boards (Greenhouse, Lever, Ashby), Adzuna and RemoteOK, tidies them into one shape, drops stale and duplicate rows, and saves the survivors into a Supabase jobs table.
Scores each job against the candidate
Each morning it pulls fresh jobs, removes ones already matched, and sends batches to Claude with the candidate profile and acceptable locations. Claude returns a 0 to 100 score, a fit note and any concerns for every job.
Emails only the real matches
It keeps jobs at or above the role's min_score, saves them to Supabase so they never repeat, and sends a ranked digest through Resend. A separate error branch emails me if the run fails.
Want this running for you?
Take the file and wire it to your own candidate and job sources, or book a free call and we set it up in your stack for you.
Build it step by step
Every node in order, across both schedules and the error branch. The crawler fills the pool, the matcher scores it, and the error branch watches both.
- 1Every 3 Hours Trigger · Schedule
Starts the crawler on a timer so the job pool stays fresh.
Set it up: Add a Schedule Trigger set to run every 3 hours. This is the trigger for the whole crawler half of the flow.
- 2Set Search Queries · Set
Holds the role keyword that every source branch searches for.
Set it up: Add a Set node with a queries array. Put your role keyword in it, for example ["product manager"]. All three source branches read from here.
- 3Curate ATS Board List · Code
Lists the no-key ATS company boards to crawl.
Set it up: Add a Code node that returns one item per board, each with an ats type (greenhouse, lever or ashby) and a company slug. Add or remove companies to target the employers you care about.
- 4Fetch ATS Board API Data · HTTP Request
Reads each board's open job feed.
Set it up: Add an HTTP Request that builds the right URL per ATS type. Turn on retries and set neverError so a dead board returns empty data instead of stopping the crawl.
- 5Parse ATS Response · Code
Turns each ATS posting into the shared job shape.
Set it up: Add a Code node that strips HTML from the description and maps each posting to id, title, company, location, url and postedAt. This is the shape every source will share.
- 6Build Adzuna Query · Code
Turns each search keyword into an Adzuna request.
Set it up: Add a Code node that reads the queries from Set Search Queries and emits one item per keyword with a what field.
- 7Fetch Adzuna Job Listings · HTTP Request
Pulls matching jobs from the Adzuna API.
Set it up: Add an HTTP Request to the Adzuna search endpoint. Pass your app_id and app_key, ask for 50 results, and set max_days_old to 30. Turn on retries.
- 8Parse Adzuna Response · Code
Maps Adzuna results into the same job shape.
Set it up: Add a Code node that reads the results array and maps each job to the shared id, title, company, location, url and postedAt fields.
- 9Fetch RemoteOK Jobs · HTTP Request
Pulls the open RemoteOK feed.
Set it up: Add an HTTP Request to the RemoteOK API. No key is needed. Turn on retries and continue on error so a slow feed does not stop the run.
- 10Parse RemoteOK Data · Code
Maps every RemoteOK listing into the shared job shape.
Set it up: Add a Code node that skips header rows, then maps each listing to id, title, company, a Remote location, url and postedAt.
- 11Merge Job Sources · Merge
Combines the three source branches into one stream.
Set it up: Add a Merge node with three inputs, and wire Parse ATS Response, Parse Adzuna Response and Parse RemoteOK Data into inputs 0, 1 and 2.
- 12Normalize and Deduplicate Jobs · Code
Drops stale and repeated jobs and flags the fresh ones.
Set it up: Add a Code node that keeps one row per id, drops anything older than 90 days, and marks jobs 60 days or newer as fresh. It returns the clean rows ready for the pool.
- 13Upsert Jobs to Pool · HTTP Request (Supabase)
Saves the clean rows into the Supabase jobs table.
Set it up: Add an HTTP Request POST to your Supabase jobs table with on_conflict=id. Set the Prefer header to merge-duplicates so a re-crawl updates the same row instead of adding a copy. That merge-on-a-key behavior is what people call an upsert.
- 14Daily Morning Trigger · Schedule
Starts the matcher once every morning.
Set it up: Add a second Schedule Trigger set to a cron of 0 8 * * *, so it fires at 08:00. This is the trigger for the matcher half of the flow.
- 15Set Role Configuration · Set
Holds the one role profile the matcher scores against.
Set it up: Add a Set node with id, email, function, locations, min_score and a profile text field. The profile is the candidate description Claude reads, so write real seniority, domain, skills and locations.
- 16Fetch Pool Shortlist · HTTP Request (Supabase)
Reads the fresh jobs that match the role function.
Set it up: Add an HTTP Request GET to the Supabase jobs table filtered by fresh, ordered by posted_at newest first, limited to 15, and filtered by the role function.
- 17Get Already Matched IDs · Supabase
Reads the jobs this candidate has already been sent.
Set it up: Add a Supabase node set to get all rows from user_job_matches where user_id equals the role id. These are the jobs the next step will skip.
- 18Create Scoring Batches · Code
Drops repeats and prepares each Claude request.
Set it up: Add a Code node that removes jobs already in the matched list, chunks the rest into batches of 20, and builds each Claude request with the candidate profile, the acceptable locations, and a forced tool call for structured scores.
- 19Score with Claude API · HTTP Request (Anthropic)
Sends each batch to Claude and gets scores back.
Set it up: Add an HTTP Request POST to the Anthropic messages endpoint. Pass your API key in the x-api-key header and the anthropic-version header. Claude returns a score, a fit note and concerns per job. Turn on retries.
- 20Filter Scored Jobs · Code
Keeps only the jobs at or above the threshold.
Set it up: Add a Code node that lines each score back up with its job by position, keeps only jobs at or above the role's min_score, and sorts them high to low.
- 21Save Job Matches · HTTP Request (Supabase)
Records the kept matches so they never repeat.
Set it up: Add an HTTP Request POST to user_job_matches with on_conflict=user_id,job_id. Set the Prefer header to ignore-duplicates so a job already saved is left alone and never sent twice.
- 22Send Email Digest · Resend
Emails the candidate the day's ranked matches.
Set it up: Add a Resend node that builds an HTML list of the kept matches with score, title, company, fit note and apply link, and sends it to the role email.
- 23Error Trigger · Error Trigger
Catches any hard failure in either schedule.
Set it up: Add an Error Trigger node. It fires only when a run fails outright, and it feeds the alert branch.
- 24Format Error Alert · Code
Builds the alert message.
Set it up: Add a Code node that reads the workflow name, the failed node and the error message, and builds an email body with a link to the execution.
- 25Send Alert Notification · HTTP Request
Emails the alert to me through Resend.
Set it up: Add an HTTP Request POST to the Resend emails endpoint with your API key, so a failed run reaches your inbox right away.
What made it tricky?
Three things ate most of the build time. If you rebuild this, they are where it goes wrong first.
Not re-sending the same job
The pool refreshes every three hours, so without a guard a candidate would see the same posting daily. Get Already Matched IDs reads prior matches, Create Scoring Batches filters them out before scoring, and Save Job Matches writes on user_id and job_id and ignores rows that already exist. Three steps, one promise: each job appears once.
Getting a clean score out of the model
Free-text scores are messy to read back. I force a report_scores tool call so Claude returns a tidy array of score, fit and concerns keyed by position. Filter Scored Jobs lines those back up with the original jobs by position, so a batch of 20 matches exactly 20 results.
One dead source not killing the run
ATS boards go down and feeds rate-limit. Every fetch keeps going on an error and retries, and the ATS request uses neverError so a 404 becomes empty data instead of a thrown error. Merge Job Sources just gets fewer items, and the Error Trigger only fires on a real hard failure.
How do I adapt it to my own setup?
Almost everything you change lives in two Set nodes and the scoring prompt. You rarely touch the plumbing.
| What to change | Where | Why |
|---|---|---|
| Candidate profile and locations | Set Role Configuration | This is what Claude scores against. Write real seniority, domain, skills and acceptable locations. |
| Role keyword | Set Search Queries and the function field | Drives what the sources search for and how the pool is filtered before scoring. |
| ATS boards | Curate ATS Board List | Add or remove Greenhouse, Lever and Ashby company slugs to target the employers you care about. |
| Score threshold | min_score in Set Role Configuration | Raise it for fewer, stronger matches, lower it for wider coverage. |
| Credentials | Adzuna, Supabase, Anthropic and Resend nodes | Paste your own API keys and Supabase URL so the pool, the scoring and the email all run under your accounts. |
You need a Supabase jobs table and a user_job_matches table with the right conflict keys, plus an Anthropic key for scoring and a Resend sender for the digest. Everything else is copy, paste and edit. If you would rather not wire up the tables and API auth yourself, this is exactly the kind of build you can hire an n8n expert to finish. For a lighter variant, see how I auto-match and rank jobs with n8n and AI or screen job applicants automatically with n8n.
Wondering what that costs? See what an n8n expert costs in 2026.
Candidate matching questions, straight answers.
How do I match candidates to open roles with n8n?
Build a job pool on a schedule by pulling postings from ATS boards, Adzuna and RemoteOK, tidy them into one shape and drop duplicates into Supabase. Then each morning pull the fresh jobs, remove the ones already matched, score the rest against a candidate profile with Claude, keep everything above your threshold, and email the digest.
How does the scoring actually decide a match?
My Create Scoring Batches node builds a prompt from the candidate profile and the acceptable locations, then Score with Claude API returns a 0 to 100 score, a short fit note and any concerns for each job. Filter Scored Jobs keeps only jobs at or above the role's min_score. The candidate profile does all the real work, so write it carefully.
Why split the workflow into a crawler and a matcher?
The Every 3 Hours Trigger keeps the Supabase job pool fresh from three sources. The Daily Morning Trigger runs the scoring once at 08:00. Keeping them apart means I score against a pool that is already full instead of crawling and matching in one slow pass, and the pool stays useful for more than one role.
How does it avoid re-sending the same job every day?
Before scoring, Get Already Matched IDs reads the user_job_matches table for that role, and Create Scoring Batches drops any job whose id is already there. Save Job Matches then writes on user_id and job_id and ignores rows that already exist. So a candidate only ever sees a given posting once, even though the pool refreshes all day.
Can I match more than one candidate or role?
Yes. The template ships one Set Role Configuration node with a single role id, email, function, locations, min_score and profile. To run several, copy the matcher branch per role or feed role rows from a table into the same scoring path. The shared job pool feeds every role, so you crawl once and score many.
How fresh are the jobs it scores?
Fetch Pool Shortlist only asks Supabase for rows flagged fresh, ordered by posted_at, filtered by the role function, limited to 15. Normalize and Deduplicate Jobs drops anything older than 90 days and marks jobs 60 days or newer as fresh. So the candidate sees recent postings, not a stale backlog from months ago.
What happens if a source or the API fails?
Most nodes keep going on an error and retry, so one dead ATS board or a flaky fetch does not kill the run. A dedicated Error Trigger catches any hard failure, Format Error Alert builds a message with the workflow and node name, and Send Alert Notification emails me through Resend so I know before the candidate does.
Want this matching your candidates?
30 minutes, free. Bring your candidate profile and the roles you hire for. You leave with a plan to get scored, ranked matches into one inbox every morning, whether you build it or we do.
Keep reading.
See all your ad spend in one place
One flow pulls Meta, Google, TikTok, and Taboola spend into Airtable, cleaned up and refreshed all day.
Catch a tired ad before it wastes money
Every morning it checks each ad against its own past clicks and flags the ones going stale in Slack.
Read every application in seconds
It scores each applicant against your must-haves, saves everyone for a human, and hands HR the top three.
Rather have it built for you?
Skip the build. A free 30-minute call and we set this up in your stack, live in a week or two.