How I match candidates to roles with n8n

The short version

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.

Loading workflow…
Free download

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.

Get your free audit

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.

  1. 1
    Every 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.

  2. 2
    Set 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.

  3. 3
    Curate 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.

  4. 4
    Fetch 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.

  5. 5
    Parse 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.

  6. 6
    Build 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.

  7. 7
    Fetch 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.

  8. 8
    Parse 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.

  9. 9
    Fetch 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.

  10. 10
    Parse 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.

  11. 11
    Merge 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.

  12. 12
    Normalize 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.

  13. 13
    Upsert 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.

  14. 14
    Daily 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.

  15. 15
    Set 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.

  16. 16
    Fetch 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.

  17. 17
    Get 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.

  18. 18
    Create 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.

  19. 19
    Score 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.

  20. 20
    Filter 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.

  21. 21
    Save 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.

  22. 22
    Send 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.

  23. 23
    Error 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.

  24. 24
    Format 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.

  25. 25
    Send 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 changeWhereWhy
Candidate profile and locationsSet Role ConfigurationThis is what Claude scores against. Write real seniority, domain, skills and acceptable locations.
Role keywordSet Search Queries and the function fieldDrives what the sources search for and how the pool is filtered before scoring.
ATS boardsCurate ATS Board ListAdd or remove Greenhouse, Lever and Ashby company slugs to target the employers you care about.
Score thresholdmin_score in Set Role ConfigurationRaise it for fewer, stronger matches, lower it for wider coverage.
CredentialsAdzuna, Supabase, Anthropic and Resend nodesPaste 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.

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.