- API reference
- Objects
The Job object
Every field on a posting, its type, when it's null, and the derivations that make a few of them surprising.
The shape returned by /v1/jobs,
/v1/jobs/feed,
/v1/jobs/modified, and
/v1/jobs/{id}. The list endpoints wrap it
in an envelope ({ data, total, limit, offset, next_cursor } on /v1/jobs);
the retrieve endpoint returns it at the top level.
Nearly every field is nullable. Postings come from 64 different sources
with no shared schema, so what one board publishes as a structured salary another
buries in prose. Write clients that expect null everywhere except id.
Example#
{
"id": "ashby:7c1f2a94-3e17-4d8b-9c05-1f6ab2e40d33",
"title": "Staff Backend Engineer",
"company": {
"slug": "linear",
"name": "Linear",
"domain": "linear.app",
"website": "https://linear.app",
"logo": "https://logo.hyperjobs.io/linear.app",
"industry": "Software Development",
"employee_count": 120
},
"location": {
"remote": true,
"countries": ["United States"],
"cities": null,
"regions": null,
"coords": null,
"timezones": ["America/New_York"],
"continent": "North America",
"raw": ["Remote (US)"]
},
"employment_type": ["FULL_TIME"],
"work_arrangement": "Remote",
"office_days": null,
"remote_location": ["United States"],
"department": "Engineering",
"team": "Sync Engine",
"requisition_id": "ENG-142",
"seniority": "Mid-Senior level",
"experience_level": "5-10",
"salary": {
"min": 190000,
"max": 240000,
"currency": "USD",
"unit": "YEAR",
"annual_min": 190000,
"annual_max": 240000,
"summary": "$190,000 – $240,000 a year"
},
"working_hours": 40,
"skills": ["typescript", "graphql", "postgresql"],
"keywords": ["distributed systems", "real-time sync"],
"taxonomies": ["Software"],
"education_requirements": ["bachelor degree"],
"visa_sponsorship": null,
"job_language": "en",
"requirements_summary": "8+ years building distributed backend systems…",
"core_responsibilities": "Own the sync engine's server-side…",
"benefits": ["Equity", "Health, dental & vision", "401(k) matching"],
"hiring_manager": null,
"apply_url": "https://jobs.ashbyhq.com/linear/7c1f2a94…",
"url": "https://jobs.ashbyhq.com/linear/7c1f2a94…",
"source": "ashby",
"source_type": "ats",
"source_domain": "jobs.ashbyhq.com",
"posted_at": "2026-07-08T14:02:11.000Z",
"expires_at": null,
"modified_at": "2026-07-12T08:30:05.000Z",
"modified_fields": ["salary_max"],
"created_at": "2026-07-08T14:10:22.000Z",
"updated_at": "2026-07-14T02:15:40.000Z"
}There's no description key in that response, and that's the default — see
the description field.
Identity#
idStable, source-prefixed identifier — ashby:7c1f2a94-…,
greenhouse:12345. The prefix names the board the posting came from, but
as a stored value, not the public slug — a Lever id starts lever.co:
even though its source field reads lever. Ids are identifiers, not
vocabulary; read the board from the source field instead of parsing it.
The only field that is never null. Pass it to
/v1/jobs/{id} exactly as received —
there's no normalisation beyond URL-decoding.
titleThe job title, cleaned up for display. This is not the raw string the
?title= filter searches — see de-SHOUTing.
sourcesource_typeProvenance class of the source: ats — a board the employer authors and
manages applicants on, the canonical record (join.com included);
jobboard — a third-party board (LinkedIn, Wellfound, …); aggregator —
a re-listing site. Filterable via ?source_type= (comma = OR). Current
pool: ~616k ats, ~87k jobboard — but read live splits off
/v1/meta.sources rather than trusting this
sentence.
source_domainThe careers-site host the posting was scraped from —
jobs.ashbyhq.com, boards.greenhouse.io, or the company's own careers
domain. Finer-grained provenance than source.
urlThe posting's canonical URL on the source board.
apply_urlWhere to apply. Falls back to url when the board doesn't publish a distinct
apply link, so in practice these two are often identical.
This is the employer's own application form — postings are read from company boards, not from an aggregator, so sending a candidate here sends them to the real ATS. See attribution.
Company#
companyThe employer. Comes back in one of three shapes, and telling them apart matters — see the three shapes.
The full object carries considerably more than the seven fields above — funding, headcount, HQ, specialties. The Company object documents all of it.
hiring_manager{ name, email } when the posting names a hiring manager or recruiting
contact; null when it doesn't (which is most of the time). Either field
inside can be null on its own — a name without an email is common.
company has three shapes#
| Shape | When | How to detect |
|---|---|---|
Full PublicCompany | The posting resolved to an enriched company profile. 99.9997% of jobs. | company.slug is present |
| Minimal fallback | No profile matched, but the posting carries its own employer details. | company is non-null, company.slug is absent |
null | The posting has no employer name at all. Vanishingly rare. | company === null |
The minimal fallback has exactly six fields — name, domain, website,
logo, industry, description — built from what the posting itself declared.
It looks like a full company object until you reach for employee_count or
funding and get undefined.
// Don't branch on field-by-field presence. Branch on `slug`.
const isEnriched = job.company?.slug != null;
if (isEnriched) {
const full = await getCompany(job.company.slug); // /v1/companies/{slug}
}Since a fallback company has no slug, there's nothing to look up on
/v1/companies/{slug}. Guard the
lookup rather than building a URL with undefined in it — that's a
404, not an empty result.
Location#
locationWhere the job is. In practice never null — see the note below.
location is never null#
Every other nested object on a job collapses to null when all of its fields are
empty. location doesn't, because remote always resolves to a real boolean —
false when nothing indicates otherwise. A non-null field means the object never
qualifies for the collapse.
The consequence: a posting with no known geography still returns a location
object, just an unhelpful one.
// A job with no parsed geography at all. Not null.
"location": { "remote": false, "countries": null, "cities": null, "regions": null, "raw": null }So if (job.location) is always true and tells you nothing. Test the field you
actually need — job.location.countries?.length — instead.
Countries here are full names; company HQ is ISO-2
job.location.countries gives ["United States"]. The same country on
company.hq.country gives "US". The two
vocabularies are genuinely different, so joining a job's country against a
company's HQ country needs a mapping table in between.
The filter doesn't care: ?country=US and ?country=United States
both work (an unknown ISO-2 code is a 400). The payload
asymmetry is what remains.
Classification#
employment_typeA real array of upper-case values — ["FULL_TIME"],
["FULL_TIME","TEMPORARY"]. An array because a posting can carry several
at once. (A pre-release draft returned this as a raw JSON string needing a parse —
not anymore.)
Filter with ?employment_type=FULL_TIME.
work_arrangementOne of On-site, Remote, Hybrid. More expressive than
location.remote, which can't represent hybrid. The filter matches
exactly, case and all — a wrong value is a 400 that lists the allowed
ones.
office_daysDays per week in the office, when a hybrid posting specifies it — 3 for
"three days in office". null otherwise, including on most hybrid
postings.
remote_locationThe remote-from restriction, when the posting declares one —
["United States"] for "remote (US only)". Where you must live to take
the remote job; distinct from location.countries, which is where the
role is based.
departmentThe department the role sits in, as the board publishes it —
"Engineering", "Go-To-Market". Free-form, no fixed vocabulary.
teamThe team within the department, when the board distinguishes one —
"Sync Engine". Sparser than department.
requisition_idThe employer's own requisition / job code — "ENG-142". Useful for
joining against an employer's internal systems; opaque otherwise.
working_hoursWeekly working hours when the posting states them — 40, 32. Common on
European postings, rare elsewhere.
seniorityOne of Internship, Entry level, Associate, Mid-Senior level,
Director, Management. See enums.
experience_levelYears of experience as a band: 0-2, 2-5, 5-10, 10+. Related to
seniority but derived separately — the two can disagree, and neither is
computed from the other at read time. See
enums.
education_requirementsA real array of lower-case values — ["bachelor degree"]. (A raw JSON
string in a pre-release draft —
not anymore.)
Filter with ?education=bachelor degree.
skillsCanonical skill tokens — ["typescript", "graphql", "postgresql"].
Present on about 84% of postings.
Displayed values don't always round-trip as ?skill= filters. See
skills & taxonomies.
keywordsBroader tags than skills — domains, methods, concepts:
["distributed systems", "real-time sync"]. Multi-word and free-form,
not filterable; use them for display and client-side relevance, not
queries.
taxonomiesJob families from a fixed set of 28 Title-Case values — ["Software"],
["Finance & Accounting"]. Present on about 90% of postings.
All 28 are listed in enums.
visa_sponsorshiptrue when the posting mentions sponsorship. null means the posting is
silent, not that sponsorship is unavailable. Filter with
?visa_sponsorship=only — or exclude to hide sponsoring postings.
job_languageDetected language of the posting, e.g. en. Filter with ?language=,
which takes the ISO code (en) or the full name (English).
employment_type, education_requirements, and benefits are real arrays now#
In a pre-release draft of this API, employment_type and education_requirements
came back as raw JSON strings ("[\"FULL_TIME\"]") and benefits was a prose
blob — the single most common surprise in that payload. All three are real JSON
arrays, consistent with skills and taxonomies next to them. If your client
runs a defensive JSON.parse on them, it can go: parsing an array throws.
job.employment_type; // → ["FULL_TIME"] — a real array
job.employment_type.includes("FULL_TIME"); // → true, no parse step
job.benefits; // → ["Equity", "401(k) matching"]The values are arrays because a posting can carry several at once
(["FULL_TIME","TEMPORARY"]). The filters take bare values, not JSON:
?employment_type=FULL_TIME and ?education=bachelor degree — you never send
the encoded form. Enums lists every
value and every observed combination.
Salary#
salaryParsed compensation. null unless the posting published something machine-
readable — about 36% of the dataset qualifies.
salary collapses to null only when all seven fields are null. A posting
with nothing but a summary, or nothing but a currency, still returns an
object — so a non-null salary doesn't imply a usable number.
// Non-null salary ≠ comparable number.
const comparable = job.salary?.annual_min != null && job.salary.currency === "USD";Two traps worth internalising before you build on this: min/max are in
unit, so an hourly posting's min: 85 is not a small salary; and
?salary_gte= is range-overlap — it matches jobs whose band reaches the
value (annual_max >= X), meaning "could pay this much", not "the floor is at
least this much". For a strict floor, compare annual_min client-side. Both
are covered in salary data and
filtering.
Content#
descriptionThe full posting body. Absent unless you ask for it — see below. Present on 99.5% of postings when requested.
requirements_summaryCondensed requirements. Always plain text, and always included — it doesn't
depend on description_format.
core_responsibilitiesCondensed responsibilities. Plain text, always included.
benefitsBenefits as a real array of strings — ["Equity", "401(k) matching"].
Always included. (Prose in a pre-release draft —
not anymore.)
description is absent by default#
Pass ?description_format=text or ?description_format=html to include it.
Without it the key is missing from the JSON entirely — not null:
"description" in job; // false by default
job.description; // undefined, not null
job.description ?? ""; // "" — works, but hides which case you're intext gives clean plain text; html gives sanitized HTML. Any other value is
a 400 — parameters aren't silently dropped anymore.
The three summary fields above are cheaper if all you need is a précis: they're always present and never require the flag.
Descriptions are the bulk of a posting's bytes. Fetching them 200 at a time
from /v1/jobs is the usual reason a request
feels slow — list without them, then pull the ones you need from
/v1/jobs/{id}.
Timestamps#
posted_atWhen the posting went live, ISO 8601 UTC. Falls back to the ATS creation date
when the board doesn't publish a posting date. This is the field results are
sorted by and that ?time_frame= measures against.
expires_atWhen the posting expires, if the board declares one. Usually null — most
boards don't publish expiry, and postings are instead reaped by
board diffing.
/v1/jobs and
/v1/jobs/feed both exclude expired
postings; an expired posting stays retrievable by id while it's in the
pool, so check this field there. To learn which cached ids to purge, use
/v1/jobs/expired.
created_atWhen we first indexed the posting. This is the timestamp the
feed windows and orders by, and what
?created_gte= filters on — the anchor for incremental sync.
modified_atWhen a tracked field last changed — title, salary, apply URL, location,
and a dozen others. null until the first tracked change.
/v1/jobs/modified is the feed of
these changes.
modified_fieldsWhich tracked fields changed in the most recent modification —
["salary_max", "apply_url"]. The vocabulary is listed on
/v1/jobs/modified.
updated_atWhen the posting's row last changed for any reason, falling back to when it
was first ingested. Broader and noisier than modified_at — prefer
modified_at for "did something meaningful change".
All of these are ISO 8601 strings in UTC (2026-07-08T14:02:11.000Z), never
epoch numbers.
The fallbacks mean these fields are rarely null, but they don't all mean quite
the same thing across postings: a posted_at may be a board's real publication
date or the ATS record's creation date, and an updated_at may be a genuine
edit or simply our first sight of the posting. They're reliable for ordering
and for "newer than X". They're weaker as claims about the employer's own
timeline.
openings#
openingsNumber of postings folded into this row. Only present when you pass
?collapse=true — the key is absent from every uncollapsed response.
collapse=true folds a company's repeats of the same title across many cities
into one row. The surviving row is a real posting, not a synthetic one, and
openings tells you how many it stands for. See
collapsing.
Derivations#
Four fields aren't stored as returned. Each of these is deliberate, and each has a consequence worth knowing.
title is de-SHOUTed#
Boards publish a lot of SENIOR SOFTWARE ENGINEER, EMEA. The returned title is
normalised for display:
| Published | Returned |
|---|---|
SENIOR SOFTWARE ENGINEER | Senior Software Engineer |
QA ANALYST II | QA Analyst II |
DIRECTOR OF SALES AND MARKETING | Director of Sales and Marketing |
ALL-CAPS titles become Title Case, two- and three-letter acronyms (HR, QA,
IT) keep their capitalisation, and minor words (of, and, the) are
lower-cased. Titles that weren't shouting are left alone.
`?title=` searches the raw title, not the one you got back
The cleanup happens on the way out. The stored value is untouched, and the
?title= filter matches against that — so the string in your response is
not guaranteed to be the string the filter sees.
It rarely bites, because title search is
word-based and case-insensitive:
?title=engineer finds both ENGINEER and Engineer. It can bite on
quoted phrases whose punctuation or spacing de-SHOUTing changed, or when
you feed a returned title back in verbatim expecting an exact hit. Search
for distinctive words, not the whole title.
apply_url falls back to url#
apply_url is the board's apply link when there is one, and the posting URL
otherwise. There's no way to tell which you got — compare the two fields if you
care.
posted_at and updated_at fall back#
posted_at uses the board's posting date, then the ATS creation date.
updated_at uses the board's update date, then our first ingestion time. Neither
is null just because the board published nothing.
location never collapses#
Covered above — remote always resolves, so the
object always survives.
Nullability at a glance#
| Field | Null when |
|---|---|
id | Never |
location | Never (in practice — remote always resolves) |
company | Only if the posting carries no employer name at all |
salary | All seven of its fields are null (~63% of postings) |
description | Absent, not null, unless description_format is set |
openings | Absent unless collapse=true |
| Everything else | Whenever the source board didn't publish it |
Related#
The full shape of job.company when it's enriched.
Every valid value for seniority, employment_type, taxonomies, and the rest.
Which fields are filterable, and the matching mode behind each one.
Sources & ATS coverageWhat source means, the values it can take, and which are live.