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

Source: https://hyperjobs.io/blog/the-company-join

*HyperJobs Team · 16 June 2026 · 6 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`](/docs/api-reference/jobs-list), `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](/docs/objects/job) 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](/docs/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:

| Shape | When | How to detect |
| --- | --- | --- |
| **Full profile** | The posting resolved to a company | `company.slug` is present |
| **Minimal fallback** | Nothing matched. Six fields, taken from the posting itself | `company` is non-null, `company.slug` is **absent** |
| `null` | The posting names no employer at all | `company === 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.

<Warning title="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}`](/docs/api-reference/companies-retrieve) either. Guard
  the lookup rather than building a URL with `undefined` in it.
</Warning>

The unresolved case is rare — the [coverage snapshot](/docs/reference/coverage)
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](/docs/objects/company).

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

## Why it is a join and not a search

There are no firmographic filters. [`/v1/companies`](/docs/api-reference/companies-list)
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](/docs/guides/filtering) 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](/docs/guides/companies) 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](/docs/quickstart), then read
[filtering](/docs/guides/filtering) — it has two behaviours that will otherwise
cost you an afternoon.
