Integration with Ashby public jobs API
Ashby is an applicant tracking system (ATS) used by many startups and scale-ups. Ashby exposes a lightweight, unauthenticated Job Postings API so you can list published openings on a custom careers page or aggregate jobs into a job board.
This post shows how to fetch public job listings from Ashby with Node.js, normalize the response, and filter to listed roles only. For other ATS public feeds, see the Greenhouse, Workable, and Lever posts.
Prerequisites
- Node.js version 26
- A company's Ashby job board name (see below)
- No API key required for the public posting API
Find the job board name
Open the company's Ashby-hosted careers page. The URL looks like https://jobs.ashbyhq.com/{JOB_BOARD_NAME}. The last path segment is the identifier you pass to the API.
Examples: notion for Notion (https://jobs.ashbyhq.com/notion), ashby for Ashby itself.
Ashby also offers authenticated endpoints (for example jobPosting.list) for customers who need filters or private data. The public API documented here is read-only and scoped to one job board at a time.
API overview
| Item | Value |
|---|---|
| Base URL | https://api.ashbyhq.com/posting-api/job-board/{JOB_BOARD_NAME} |
| Auth | None for read access |
| Format | JSON |
| Optional query | includeCompensation=true adds salary bands when the employer exposes them |
Common fields on each job in jobs[]:
| Field | Description |
|---|---|
title | Job title |
location | Primary location label |
secondaryLocations | Additional offices or regions |
isRemote, workplaceType | Remote / hybrid / on-site hints |
isListed | When false, the role is unlisted and should not appear on a public board |
jobUrl, applyUrl | Hosted Ashby pages |
descriptionPlain | Plain-text description (when provided) |
publishedAt | Publish timestamp |
Basic integration
Fetch all published jobs for one board:
const boardName = process.env.ASHBY_BOARD_NAME ?? 'notion';const url = new URL(`https://api.ashbyhq.com/posting-api/job-board/${encodeURIComponent(boardName)}`,);url.searchParams.set('includeCompensation', 'true');const response = await fetch(url);if (!response.ok) {throw new Error(`Ashby API ${response.status}: ${response.statusText}`);}const data = await response.json();const jobs = data.jobs ?? [];for (const job of jobs) {console.log(job.title, '-', job.location, '-', job.jobUrl);}
Filter to roles that are safe to show publicly and have a link:
const publicJobs = jobs.filter((job) => (job.isListed ?? true) && job.title && (job.jobUrl || job.applyUrl),);
Normalize to a stable shape
Different ATS products use different field names. Map Ashby jobs into a schema your app owns:
function normalizeAshbyJob(job, companyName) {const locations = [job.location, ...(job.secondaryLocations ?? []).map((s) => s.location)].map((value) => value?.trim()).filter(Boolean);const isRemote =Boolean(job.isRemote) ||job.workplaceType?.toLowerCase() === 'remote' ||locations.some((loc) => /remote/i.test(loc));return {id: job.id ?? `${job.title}:${job.publishedAt}`,title: job.title.trim(),company: companyName,location: locations.join(' | ') || 'Unknown',isRemote,url: job.jobUrl || job.applyUrl,postedAt: job.publishedAt ? new Date(job.publishedAt) : null,description: job.descriptionPlain ?? '',compensation: job.compensation ?? null,};}
When the primary location is generic (Remote, Hybrid) but postal data includes a country, append the country so filters still work:
function enrichLocation(location, country) {const label = location?.trim();const c = country?.trim();if (!label || !c) return label ?? '';if (/^(remote|hybrid|on-?site)$/i.test(label) && !label.toLowerCase().includes(c.toLowerCase())) {return `${label}, ${c}`;}return label;}
Ashby may return "European Union" in address.postalAddress.addressCountry; treat that as a region hint, not a country code.