this week I posted on X about Career Hound, a job board run by Roman Lakhnyuk. per the Starter Story interview, it pulls job listings straight from company websites and charges behind a hard paywall, $7 a week, $20 a month or $50 a year. Roman doesn't code, and the dashboard he showed had $12K MRR
the business side is a distribution story. but the dev in me got stuck on the boring part: how do you actually pull jobs "straight from company websites" without writing a scraper per company?
I don't know how Career Hound does it, and this isn't their code. but I know one cheap way to do it, because a lot of companies don't host their own careers page. they use an applicant tracking system (ATS) like Greenhouse, Lever or Ashby, and all three expose a public, unauthenticated JSON endpoint for the job board. no HTML parsing, no headless browser
so this is a small TypeScript job sync that reads those three, normalizes them into one shape and tells you what's new since the last run. Node 20+, zero dependencies, I ran everything below with npx tsx
the three endpoints
-
Greenhouse:
https://boards-api.greenhouse.io/v1/boards/{slug}/jobs -
Lever:
https://api.lever.co/v0/postings/{slug}?mode=json -
Ashby:
https://api.ashbyhq.com/posting-api/job-board/{slug}
the slug is just the company name in their hosted board URL. job-boards.greenhouse.io/vercel means the slug is vercel. jobs.ashbyhq.com/ashby means ashby. you can paste these in the browser and see the raw JSON
each one returns a different shape, which is the annoying part. Greenhouse gives you title, location.name, absolute_url. Lever gives you text for the title, categories.location, hostedUrl and createdAt as a unix ms number. Ashby gives you title, location, isRemote, jobUrl. so step one is one type to rule them
one shape for every job
// jobs.mts
type Job = {
id: string;
source: "greenhouse" | "lever" | "ashby";
company: string;
title: string;
location: string;
remote: boolean;
url: string;
postedAt: string | null;
};
type Board = { ats: Job["source"]; slug: string };
async function getJSON<T>(url: string): Promise<T> {
const res = await fetch(url, { headers: { "user-agent": "jobs-sync/0.1" } });
if (!res.ok) throw new Error(`${res.status} on ${url}`);
return res.json() as Promise<T>;
}
I prefix ids with the source (gh:, lv:, ab:) so two ATSs can never collide. that matters later for the diff
one adapter per ATS
async function fromGreenhouse(slug: string): Promise<Job[]> {
const data = await getJSON<{ jobs: any[] }>(
`https://boards-api.greenhouse.io/v1/boards/${slug}/jobs`
);
return data.jobs.map((j) => ({
id: `gh:${j.id}`,
source: "greenhouse",
company: j.company_name ?? slug,
title: j.title,
location: j.location?.name ?? "",
remote: /remote/i.test(j.location?.name ?? ""),
url: j.absolute_url,
postedAt: j.first_published ?? j.updated_at ?? null,
}));
}
async function fromLever(slug: string): Promise<Job[]> {
const data = await getJSON<any[]>(
`https://api.lever.co/v0/postings/${slug}?mode=json`
);
return data.map((j) => ({
id: `lv:${j.id}`,
source: "lever",
company: slug,
title: j.text,
location: j.categories?.location ?? "",
remote: j.workplaceType === "remote",
url: j.hostedUrl,
postedAt: j.createdAt ? new Date(j.createdAt).toISOString() : null,
}));
}
async function fromAshby(slug: string): Promise<Job[]> {
const data = await getJSON<{ jobs: any[] }>(
`https://api.ashbyhq.com/posting-api/job-board/${slug}`
);
return data.jobs
.filter((j) => j.isListed !== false)
.map((j) => ({
id: `ab:${j.id}`,
source: "ashby",
company: slug,
title: j.title,
location: j.location ?? "",
remote: Boolean(j.isRemote),
url: j.jobUrl,
postedAt: j.publishedAt ?? null,
}));
}
a couple of things I noticed while testing. Greenhouse has no clean remote flag in the list endpoint, so I regex the location name, which is good enough for "Remote - Germany" style labels but not perfect. Lever and Ashby both tell you directly. and Ashby returns isListed, so I drop unlisted ones
I typed the raw responses as any on purpose. for a side project I'd rather have the mapping in one obvious place than maintain three big response types. if this grows I'd put zod on the raw payloads
fetch everything, don't let one board kill the run
const fetchers = { greenhouse: fromGreenhouse, lever: fromLever, ashby: fromAshby };
async function syncAll(boards: Board[]) {
const results = await Promise.allSettled(
boards.map((b) => fetchers[b.ats](b.slug))
);
const jobs: Job[] = [];
results.forEach((r, i) => {
if (r.status === "fulfilled") jobs.push(...r.value);
else console.warn(`skip ${boards[i].ats}/${boards[i].slug}: ${r.reason}`);
});
return jobs;
}
const boards: Board[] = [
{ ats: "greenhouse", slug: "vercel" },
{ ats: "lever", slug: "leverdemo" },
{ ats: "ashby", slug: "ashby" },
{ ats: "greenhouse", slug: "this-board-does-not-exist" },
];
const jobs = await syncAll(boards);
const remote = jobs.filter((j) => j.remote);
console.log(`${jobs.length} jobs, ${remote.length} remote`);
Promise.allSettled is the important bit. companies switch ATS or rename their board all the time, and with Promise.all one 404 throws away every other result. I left a fake slug in the list to prove it: the run logs skip greenhouse/this-board-does-not-exist: Error: 404 ... and keeps going with the rest
when I ran it today it came back with 170 jobs across the three real boards. that number will be different when you run it, it's live data
with a few hundred companies you'd want a small concurrency limit instead of firing everything at once, just to be polite to the APIs. a simple chunk loop of 10 at a time is enough
what's new since last run
a job board is only useful if it shows fresh jobs, so the next step is remembering what you've already seen. for a first version a JSON file is fine:
import { readFile, writeFile } from "node:fs/promises";
type Seen = Record<string, string>; // job id -> first time we saw it
async function newJobsSince<T extends { id: string }>(jobs: T[], file = "seen.json") {
const seen: Seen = JSON.parse(await readFile(file, "utf8").catch(() => "{}"));
const now = new Date().toISOString();
const fresh = jobs.filter((j) => !seen[j.id]);
for (const j of fresh) seen[j.id] = now;
await writeFile(file, JSON.stringify(seen, null, 2));
return fresh;
}
first run, everything is new. second run, only what got posted in between. that's your "new today" email or feed. in production I'd swap the file for a table with a unique index on id and use insert ... on conflict do nothing, but the logic is the same
finding the board from a careers link
the last annoying step is figuring out which ATS a company uses. most careers pages link out to the hosted board, so you can detect it from the URL:
function detectBoard(url: string) {
const u = new URL(url);
const slug = u.pathname.split("/").filter(Boolean)[0];
if (u.hostname.endsWith("greenhouse.io")) return { ats: "greenhouse", slug };
if (u.hostname === "jobs.lever.co") return { ats: "lever", slug };
if (u.hostname === "jobs.ashbyhq.com") return { ats: "ashby", slug };
return null;
}
feed it https://job-boards.greenhouse.io/vercel/jobs/6231864004 and you get { ats: "greenhouse", slug: "vercel" }. a null means the company hosts its own page or uses another ATS, and that's where real scraping starts. I'd leave those for later
what I'd do next
- run
syncAllon a cron (a Cloudflare Worker cron trigger or a GitHub Action is plenty) - keep the description out of the list call and fetch it only for jobs someone opens. Greenhouse has
?content=trueif you want it inline - add filters people actually pay for: remote only, country, posted in the last 24h
the code here is maybe 100 lines and it covers three of the big ATSs. what made Career Hound work, per Roman, was the content and the paywall, not the tech. but it's nice to know the tech side of a "simple website" can really be this small
sources
- Starter Story, "I make $25K/month from one simple website"
- Career Hound
- Greenhouse Job Board API docs
- Lever Postings API docs
- Ashby public job posting API docs
I post indie app cases and build logs like this on X and Instagram
Top comments (0)