Objects / The Job object

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#

A job with everything populated
{
  "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#

id
string

Stable, 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.

title
string | null

The job title, cleaned up for display. This is not the raw string the ?title= filter searches — see de-SHOUTing.

source
string | null

The board the posting was ingested from: ashby, greenhouse, lever… This field, the ?source= filter, and the keys of /v1/meta.sources all share one vocabulary — the public slug. A value read here can be fed straight back as a filter. See sources.

source_type
string | null

Provenance 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_domain
string | null

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

url
string | null

The posting's canonical URL on the source board.

apply_url
string | null

Where 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#

company
object | null

The 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
object | null

{ 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#

ShapeWhenHow to detect
Full PublicCompanyThe posting resolved to an enriched company profile. 99.9997% of jobs.company.slug is present
Minimal fallbackNo profile matched, but the posting carries its own employer details.company is non-null, company.slug is absent
nullThe 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.

JS
// 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#

location
object

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

JSON
// 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_type
string[] | null

A 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_arrangement
string | null

One 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_days
number | null

Days 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_location
string[] | null

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

department
string | null

The department the role sits in, as the board publishes it — "Engineering", "Go-To-Market". Free-form, no fixed vocabulary.

team
string | null

The team within the department, when the board distinguishes one — "Sync Engine". Sparser than department.

requisition_id
string | null

The employer's own requisition / job code — "ENG-142". Useful for joining against an employer's internal systems; opaque otherwise.

working_hours
number | null

Weekly working hours when the posting states them — 40, 32. Common on European postings, rare elsewhere.

seniority
string | null

One of Internship, Entry level, Associate, Mid-Senior level, Director, Management. See enums.

experience_level
string | null

Years 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_requirements
string[] | null

A real array of lower-case values — ["bachelor degree"]. (A raw JSON string in a pre-release draft — not anymore.) Filter with ?education=bachelor degree.

skills
string[] | null

Canonical skill tokens — ["typescript", "graphql", "postgresql"]. Present on about 84% of postings.

Displayed values don't always round-trip as ?skill= filters. See skills & taxonomies.

keywords
string[] | null

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

taxonomies
string[] | null

Job 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_sponsorship
boolean | null

true 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_language
string | null

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

JS
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#

salary
object | null

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

JS
// 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#

description
string | null

The full posting body. Absent unless you ask for it — see below. Present on 99.5% of postings when requested.

requirements_summary
string | null

Condensed requirements. Always plain text, and always included — it doesn't depend on description_format.

core_responsibilities
string | null

Condensed responsibilities. Plain text, always included.

benefits
string[] | null

Benefits 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:

JS
"description" in job;      // false by default
job.description;           // undefined, not null
job.description ?? "";     // "" — works, but hides which case you're in

text 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_at
string | null

When 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_at
string | null

When 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_at
string | null

When 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_at
string | null

When 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_fields
string[] | null

Which tracked fields changed in the most recent modification — ["salary_max", "apply_url"]. The vocabulary is listed on /v1/jobs/modified.

updated_at
string | null

When 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#

openings
number

Number 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:

PublishedReturned
SENIOR SOFTWARE ENGINEERSenior Software Engineer
QA ANALYST IIQA Analyst II
DIRECTOR OF SALES AND MARKETINGDirector 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 aboveremote always resolves, so the object always survives.

Nullability at a glance#

FieldNull when
idNever
locationNever (in practice — remote always resolves)
companyOnly if the posting carries no employer name at all
salaryAll seven of its fields are null (~63% of postings)
descriptionAbsent, not null, unless description_format is set
openingsAbsent unless collapse=true
Everything elseWhenever the source board didn't publish it
Was this page helpful?