JobsPipe and BetterJobs look alike from the outside: both search jobs with POST /v1/jobs/search and bill per job. The differences are inside: BetterJobs fans the search out to JobsPipe and up to six other sources, merges the results into canonical jobs, and never bills the same job twice.
JobsPipe is one of the six providers behind BetterJobs, so its postings can still reach you through the waterfall.
Normalized job postings from 30+ sources with 12 months of history.
Sources 30+ sources incl. Greenhouse, Lever, Ashby, Workday, Indeed, LinkedIn History 12 months Freshness Under 6h on Builder, under 1h on Scale Credits 1 credit per job; one credit buys a job for the rest of the calendar month
Topic
JobsPipe (per JobsPipe docs)
BetterJobs
Search endpoint
POST /v1/jobs/search
POST /v1/jobs/search on https://api.betterjobs.cc
Sources
30+ sources incl. Greenhouse, Lever, Ashby, Workday, Indeed, LinkedIn
BetterJobs index plus up to six providers, JobsPipe included, merged
Job price
1 credit per job
1 credit per unique job
Paying again
One credit buys a job for the rest of the calendar month
A job you already paid for returns free; the ledger shows already_paid
Ghost postings
ghost_score
p_real: probability the job is real, from cross-source corroboration
Taxonomies
ISCO-08, ISIC, ESCO
job_family strings and a fixed seniority enum. No ISCO, ISIC or ESCO codes in v1 preview
History
12 months
posted_within_days up to 365, include_closed: true for closed jobs
Freshness
Under 6h on Builder, under 1h on Scale
Published at GA. Each job carries first_seen_at, last_seen_at, last_verified_at
The path is the same. Change the base URL, the auth header and the body: BetterJobs puts every filter inside one filters object and routing next to it in waterfall.
Before: POST <JobsPipe base URL>/v1/jobs/search + your JobsPipe filters and key
After: POST https://api.betterjobs.cc/v1/jobs/search
Authorization: Bearer bj_live_...
BetterJobs-Version: 2026-10-01
{ "filters": { ... }, "waterfall": { ... }, "limit": 100 }
curl https://api.betterjobs.cc/v1/jobs/search \
-H " Authorization: Bearer $BETTERJOBS_API_KEY " \
-H " BetterJobs-Version: 2026-10-01 " \
-H " Content-Type: application/json " \
"title_or": ["Senior Data Engineer", "Staff Data Engineer"],
"country_code_or": ["NL", "DE"],
"seniority_or": ["senior", "lead"],
"waterfall": { "strategy": "cheapest_first", "max_credits": 100 },
" https://api.betterjobs.cc/v1/jobs/search " ,
" Authorization " : f "Bearer {os.environ [ ' BETTERJOBS_API_KEY ' ] } " ,
" BetterJobs-Version " : " 2026-10-01 " ,
" title_or " : [ " Senior Data Engineer " , " Staff Data Engineer " ] ,
" country_code_or " : [ " NL " , " DE " ] ,
" seniority_or " : [ " senior " , " lead " ] ,
" posted_within_days " : 14 ,
" waterfall " : { " strategy " : " cheapest_first " , " max_credits " : 100 },
jobs, meta = page[ " data " ], page[ " metadata " ]
print ( meta [ " credits_charged " ] , " charged, " , meta [ " jobs_already_paid " ] , " already paid (free) " )
const res = await fetch ( ' https://api.betterjobs.cc/v1/jobs/search ' , {
Authorization: ` Bearer ${ process . env . BETTERJOBS_API_KEY } ` ,
' BetterJobs-Version ' : ' 2026-10-01 ' ,
' Content-Type ' : ' application/json ' ,
title_or: [ ' Senior Data Engineer ' , ' Staff Data Engineer ' ] ,
country_code_or: [ ' NL ' , ' DE ' ] ,
seniority_or: [ ' senior ' , ' lead ' ] ,
waterfall: { strategy: ' cheapest_first ' , max_credits: 100 },
if ( ! res . ok ) throw new Error ( ` ${ res . status } ${ await res . text () } ` );
const { data : jobs , metadata } = await res . json ();
console . log (metadata . credits_charged , ' charged, ' , metadata . jobs_already_paid , ' already paid (free) ' );
Rewrite each JobsPipe filter you use as one of the BetterJobs filters on Filters . A filter name BetterJobs does not know returns 400 unknown_filter with the name in error.param, so a missed rename fails on the first call instead of returning a wider result.
Every BetterJobs search response states its cost in metadata.credits_charged, plus the X-Credits-Charged and X-Credits-Remaining headers.
Field names in JobsPipe responses and their BetterJobs equivalents. Rows show only fields with a confident one-to-one match.
— = no confident one-to-one equivalent in that provider's published field names. BetterJobs JobsPipe titlejob_titlesenioritysenioritysalarysalary_usdposted_atdate_postedfirst_seen_atdiscovered_atlast_seen_atlast_seen_atlast_verified_atverified_atclosed_reasonclosed_reasonsources[].urlurlsources[].first_seen_atdiscovered_atsources[].last_seen_atlast_seen_at
Every BetterJobs field, with type and null meaning, is in the field dictionary .
ghost_score and p_real point in opposite directions
A high ghost_score flags a likely ghost posting. A high p_real means the job is likely real. The scales are computed differently, so 1 - ghost_score is not p_real. Re-tune your thresholds on your own data.
salary_usd is not salary
BetterJobs salary is an object: min, max, currency, period and origin (declared or inferred). Amounts stay in the posting’s currency. Convert to USD yourself if your code expects salary_usd.
Charged once, not once a month. Per JobsPipe docs, one credit buys a job for the rest of the calendar month. BetterJobs charges 1 credit the first time a job reaches you; later reads of that job in searches or GET /v1/jobs/{id} are free.
Duplicates across sources are free. If JobsPipe and another source report the same opening, you pay for one canonical job. metadata.duplicates_merged counts the folded records.
Empty pages and estimates are free. dry_run: true returns expected_unique_jobs_range and credits_range without fetching.
Proof of charge. GET /v1/billing/ledger?job_id=... lists the original charge and every free re-read.
JobsPipe is a partner provider: Growth includes two partner providers, Pro and above include all six. GET /v1/providers shows whether your plan enables jobspipe. On Scale you can bring your own provider keys. Plans are on Credits and billing .
Base URL, key and version header. See Authentication .
Wrap filters. Move them into filters and rename them to BetterJobs names.
Rename response fields. Use the table above, or the adapter below at the edge of your code.
Replace taxonomy codes. Map your ISCO-08 or ESCO lists to job_family_or and seniority_or values. See Taxonomies .
Drop month-boundary logic. Code that avoided re-fetching across months to save credits is no longer needed.
Read provenance. sources[] shows which sources saw each job; the JobsPipe record appears with provider: "jobspipe".
If downstream code reads JobsPipe names, adapt each job once:
curl -s https://api.betterjobs.cc/v1/jobs/search \
-H " Authorization: Bearer $BETTERJOBS_API_KEY " \
-H " BetterJobs-Version: 2026-10-01 " \
-H " Content-Type: application/json " \
-d ' {"filters": {"title_or": ["Senior Data Engineer"], "posted_within_days": 14}, "limit": 100} ' \
discovered_at: .first_seen_at,
last_seen_at: .last_seen_at,
verified_at: .last_verified_at,
closed_reason: .closed_reason,
url: ([.sources[].url | select(. != null)] | first),
def to_jobspipe_names ( job : dict ) -> dict :
""" Rename BetterJobs fields to the JobsPipe names your code already reads. """
" job_title " : job[ " title " ],
" date_posted " : job[ " posted_at " ], # may be null: use discovered_at then
" discovered_at " : job[ " first_seen_at " ],
" last_seen_at " : job[ " last_seen_at " ],
" verified_at " : job[ " last_verified_at " ],
" seniority " : job[ " seniority " ],
" closed_reason " : job[ " closed_reason " ], # filled | expired | removed | unknown | None
" url " : next ( (s [ " url " ] for s in job [ " sources " ] if s [ " url " ] ) , None ),
" p_real " : job[ " p_real " ], # not ghost_score: re-tune thresholds
# No salary_usd: use job["salary"] (currency, period, origin).
rows = [ to_jobspipe_names ( job ) for job in jobs ]
posted_at : string | null ;
last_verified_at : string | null ;
seniority : string | null ;
closed_reason : ' filled ' | ' expired ' | ' removed ' | ' unknown ' | null ;
sources : { url : string | null }[];
/** Rename BetterJobs fields to the JobsPipe names your code already reads. */
function toJobsPipeNames ( job : Job ) {
date_posted: job . posted_at , // may be null: use discovered_at then
discovered_at: job . first_seen_at ,
last_seen_at: job . last_seen_at ,
verified_at: job . last_verified_at ,
seniority: job . seniority ,
closed_reason: job . closed_reason ,
url: job . sources . find ( ( s ) => s . url ) ?. url ?? null ,
p_real: job . p_real , // not ghost_score: re-tune thresholds
// No salary_usd: use job.salary (currency, period, origin).
const rows = jobs . map (toJobsPipeNames);
Same path, different host. Change the base URL and the key together. A key BetterJobs does not recognize returns 401 unauthorized .
closed_reason values are a fixed enum. BetterJobs uses filled, expired, removed, unknown, or null while open. Map JobsPipe values you stored before.
Seniority values differ. Check your seniority filters against Taxonomies before copying them.
null is unknown. last_verified_at: null means never verified at origin, not “dead”.