All posts
Engineering

Why every job carries its company

Most jobs APIs hand you an employer name and leave you to resolve it. Here is what changes when the company object is already attached.

HyperJobs Team6 min read

Every jobs API returns a posting with an employer on it. Almost all of them return that employer as a string.

JSON
{ "title": "Senior C++ Engineer", "company": "Stripe" }

That string is where the work starts. You wanted senior C++ roles at fintechs with more than 200 employees, and what you have is a title and a word. The employer's size, industry, domain, funding and headquarters are all somewhere else, behind a second system you now have to own.

The shape of the problem#

The naive fix is a lookup. Search returns 200 postings, you extract 200 employer names, and you resolve each one against a firmographics source. That is the classic N+1, except worse than the database version, because the join key is a human-typed string.

Three things go wrong, and they compound:

The key is unstable. "Stripe", "Stripe, Inc.", "Stripe Inc" and "STRIPE" are one company and four strings. You end up writing normalisation rules, then exceptions to the rules, then a manual override table.

The lookups cost. 200 postings is 200 resolutions per page, on top of the search itself. You cache to survive it, and now you own a cache with an invalidation policy for data that changes.

You filter after the fact. "More than 200 employees" cannot be part of the search, because the search does not know about employees. You over-fetch, resolve everything, discard most of it, and page again because your filtered page came back half empty.

None of that is your problem. It's a missing join, pushed downstream.

What the join does#

On /v1/jobs, company is an object, not a string. It is already there, on every posting, in the same response:

JSON
{
  "id": "ashby:0f2c…",
  "title": "Senior C++ Engineer",
  "company": {
    "slug": "stripe",
    "name": "Stripe",
    "domain": "stripe.com",
    "industry": "Financial Services",
    "employee_count": 8000,
    "hq": { "country": "US", "locality": "South San Francisco" }
  },
  "location": { "remote": false, "countries": ["United States"], "cities": ["New York"] },
  "salary": { "annual_min": 180000, "annual_max": 240000, "currency": "USD" },
  "skills": ["cpp", "cuda", "distributed systems"]
}

That payload is illustrative — a real response carries more fields, and the Job object lists all of them. The structure is the point. One request, and the employer arrives resolved.

So the query at the top of this post becomes a search plus a client-side predicate over data you already hold:

JS
const qs = new URLSearchParams({
  skill: "cpp",
  seniority: "Mid-Senior level",
  time_frame: "7d",
  limit: "200",
});
 
const { data } = await fetch(`https://api.hyperjobs.io/v1/jobs?${qs}`, {
  headers: { Authorization: `Bearer ${process.env.HYPERJOBS_KEY}` },
}).then((r) => r.json());
 
// No second request. `company` came with the row.
const matches = data.filter((j) => {
  const c = j.company;
  // `slug` proves this is a full profile — see "The three shapes" below.
  if (c?.slug == null) return false;
  return c.employee_count != null && c.employee_count > 200 && c.industry === "Financial Services";
});

No resolution step. No name matching. No cache. The quickstart gets you to that response in about two minutes.

The three shapes#

Here is the first ugly part, and it matters more than its frequency suggests. job.company is not always the full object. It has three shapes:

ShapeWhenHow to detect
Full profileThe posting resolved to a companycompany.slug is present
Minimal fallbackNothing matched. Six fields, taken from the posting itselfcompany is non-null, company.slug is absent
nullThe posting names no employer at allcompany === null

The fallback carries name, domain, website, logo, industry and description — a structural subset of the full object. That subset is exactly what makes it dangerous. Your code path that reads company.name works. Your code path that reads company.employee_count gets undefined, and undefined > 200 is false, so the row silently drops out of a filter instead of raising anything.

Detect the fallback with `slug`, not with field presence

Every other candidate — industry, logo, description — is nullable on the full profile too, so its absence proves nothing. slug is the only field guaranteed on a full profile and impossible on a fallback.

JS
const isEnriched = job.company?.slug != null;

A fallback has no slug, so there is nothing to fetch from /v1/companies/{slug} either. Guard the lookup rather than building a URL with undefined in it.

The unresolved case is rare — the coverage snapshot puts it at two postings out of roughly 600,000. Handle it anyway. A null dereference on a path that fires twice in 600,000 rows is a bug you find in production, months later, in a stack trace nobody can reproduce.

The seams the join does not hide#

An embedded join is not a merged record, and pretending otherwise costs you.

Two country vocabularies, one payload. company.hq.country is ISO-2 ("US"). job.location.countries is full names (["United States"]). So job.location.countries.includes(job.company.hq.country) is always false. You need a mapping table between them. See the Company object.

HQ is not where the work is. A US-headquartered company posts roles everywhere. Filter jobs by the job's location; use hq to describe the employer, not to locate the job.

The two halves refresh independently. Job ingestion runs hourly. Company profiles refresh on their own cadence. A posting that went live an hour ago can carry a profile that is months old, and company.updated_at tells you which. That is expected behaviour, not staleness to report.

Company coverage inside a profile is uneven. name and slug are dependable. funding and stock exist for a minority, and the whole nested object collapses to null rather than filling with nulls — so company.funding.rounds needs a guard.

There are no firmographic filters. /v1/companies accepts limit and nothing else — no industry=, no min_employees=, no offset, no total. It browses the top of a fixed ordering and there is no way to page past it.

That reads like a gap, and it partly is. But the job-side join is what makes it survivable, and it answers a slightly different question than the one you asked:

Filter jobs, read the embedded company.

"Fintechs in Germany with 200+ employees" via job filters for taxonomies=Finance & Accounting and location=Germany reaches those employers through their postings — and only the ones actively hiring. For most questions that start with "which companies", the hiring qualifier was implied anyway. Company data has the pattern in full.

What this actually buys#

The join removes a system. Not a request — a system: the resolver, its normalisation rules, its override table, its cache, and the invalidation policy for that cache. That code is never interesting, it is never done, and it is wrong in a long tail of ways you discover one employer at a time.

Start with the quickstart, then read filtering — it has two behaviours that will otherwise cost you an afternoon.