# Company data

> The embedded-company model — full records, minimal fallbacks, data tiers, and how to filter firmographics server-side and client-side.

Source: https://hyperjobs.io/docs/guides/companies

Every job carries its company **inline**. Not an id to resolve, not a `_links`
stub — the whole firmographic record, in the same response, on every row.

```json
{
  "id": "greenhouse:4820193",
  "title": "Senior Data Engineer",
  "company": {
    "slug": "figma",
    "name": "Figma",
    "domain": "figma.com",
    "industry": "Software Development",
    "employee_count": 1400,
    "size_range": "1001-5000",
    "hq": { "country": "US", "region": "California", "locality": "San Francisco" },
    "funding": { "last_round_type": "Series E", "total_investment_usd": 748000000 }
  }
}
```

That's the point of the product. A job feed that gives you a company *name* means
you still have to go and find out who that company is; one request here answers
"remote Rust roles at Series-B-or-later companies with under 500 people" without
a second call, because the answer was already in the payload.

There are ~158,545 companies with firmographic records behind those embeds.

## Three shapes of `company`

`job.company` is not one type. It's one of three, and code that assumes the
first will throw on the second.

<Fields>
  <ResponseField name="Full PublicCompany" type="has `slug`">
    The job resolved to a company record. You get every field below. This is
    **99.9997%** of jobs.
  </ResponseField>
  <ResponseField name="Minimal fallback" type="no `slug`">
    No company record matched, so the object is built from the job's own
    `organization_*` columns. Only `{ name, domain, website, logo, industry,
    description }` — every firmographic field is absent.
  </ResponseField>
  <ResponseField name="null" type="—">
    Only when the posting has no organization name at all.
  </ResponseField>
</Fields>

<Tip title="Detect the shape with `slug`">
  `slug` is present on the full record and absent on the fallback. It's the only
  reliable discriminator — `name` and `domain` exist on both.

  ```js
  const isFull = (c) => Boolean(c?.slug);

  // Safe against all three shapes.
  const size = isFull(job.company) ? job.company.employee_count : null;
  ```

  Reaching for `job.company.funding.last_round_type` without this check works
  fine across thousands of rows and then throws on a fallback. Optional chaining
  hides the crash but not the bug: you'll silently treat "we don't have this
  company" as "this company has no funding".
</Tip>

## The PublicCompany record

<Fields>
  <ResponseField name="slug" type="string">
    The LinkedIn slug, and the primary key. This is what
    [`/v1/companies/{slug}`](/docs/api-reference/companies-retrieve) takes.
  </ResponseField>
  <ResponseField name="name" type="string">
    Display name.
  </ResponseField>
  <ResponseField name="domain" type="string | null">
    Primary domain, e.g. `figma.com`.
  </ResponseField>
  <ResponseField name="website" type="string | null">
    Full URL.
  </ResponseField>
  <ResponseField name="logo" type="string | null">
    Logo URL.
  </ResponseField>
  <ResponseField name="industry" type="string | null">
    Free-text industry label. Not an enum — don't filter on exact equality.
  </ResponseField>
  <ResponseField name="type" type="string | null">
    Company type, e.g. privately held, public.
  </ResponseField>
  <ResponseField name="description" type="string | null">
    Long-form company description.
  </ResponseField>
  <ResponseField name="slogan" type="string | null">
    Tagline.
  </ResponseField>
  <ResponseField name="specialties" type="string[] | null">
    Self-declared focus areas.
  </ResponseField>
  <ResponseField name="employee_count" type="number | null">
    Headcount as a number. The field to filter on.
  </ResponseField>
  <ResponseField name="size_range" type="string | null">
    Bucketed headcount, e.g. `1001-5000`. Useful when `employee_count` is null.
  </ResponseField>
  <ResponseField name="followers" type="number | null">
    LinkedIn followers. Also the secondary sort on
    [`/v1/companies`](/docs/api-reference/companies-list).
  </ResponseField>
  <ResponseField name="founded_year" type="number | null">
    Year founded.
  </ResponseField>
  <ResponseField name="hq" type="object | null">
    Headquarters location.
    <Expandable title="hq">
      <ResponseField name="country" type="string | null">
        **ISO-2 code** — `"US"`. See the warning below.
      </ResponseField>
      <ResponseField name="region" type="string | null">
        State or region.
      </ResponseField>
      <ResponseField name="locality" type="string | null">
        City.
      </ResponseField>
      <ResponseField name="raw" type="string | null">
        The unparsed original string.
      </ResponseField>
    </Expandable>
  </ResponseField>
  <ResponseField name="funding" type="object | null">
    Funding history.
    <Expandable title="funding">
      <ResponseField name="rounds" type="number | null">
        Number of rounds raised.
      </ResponseField>
      <ResponseField name="last_round_type" type="string | null">
        e.g. `Series E`.
      </ResponseField>
      <ResponseField name="last_round_date" type="string | null">
        Date of the most recent round.
      </ResponseField>
      <ResponseField name="last_round_amount_usd" type="number | null">
        Most recent round size, USD.
      </ResponseField>
      <ResponseField name="total_investment_usd" type="number | null">
        Total raised, USD.
      </ResponseField>
      <ResponseField name="crunchbase_slug" type="string | null">
        Crunchbase identifier.
      </ResponseField>
      <ResponseField name="crunchbase_categories" type="string[] | null">
        Crunchbase category tags.
      </ResponseField>
    </Expandable>
  </ResponseField>
  <ResponseField name="stock" type="object | null">
    Public listing, when there is one.
    <Expandable title="stock">
      <ResponseField name="exchange" type="string | null">
        e.g. `NYSE`.
      </ResponseField>
      <ResponseField name="ticker" type="string | null">
        e.g. `CRM`.
      </ResponseField>
    </Expandable>
  </ResponseField>
  <ResponseField name="affiliated_companies" type="array | null">
    Related entities — subsidiaries, regional arms.
  </ResponseField>
  <ResponseField name="data_tier" type="string">
    Provenance of the record. See below — there are more values than documented.
  </ResponseField>
  <ResponseField name="jobs_count" type="number">
    Live openings — non-duplicate, non-expired postings in the pool right now.
    Present on both company endpoints (not on the embed); recomputed roughly
    every 15 minutes.
  </ResponseField>
  <ResponseField name="updated_at" type="string">
    When the firmographic record was last refreshed.
  </ResponseField>
</Fields>

<Note>
  `hq`, `funding`, and `stock` collapse to `null` when all of their own fields are
  null — you never get an object full of nulls. Check the parent before reading a
  child: `company.funding?.total_investment_usd`. Same behaviour as
  [`salary`](/docs/guides/salary#the-salary-object) on a job.
</Note>

## Country codes are inconsistent

<Warning title="`hq.country` is ISO-2. `job.location.countries` is full names.">
  Two country fields, in the same response, in different formats:

  ```json
  {
    "location": { "countries": ["United States"] },
    "company":  { "hq": { "country": "US" } }
  }
  ```

  This is a genuine inconsistency, not a subtlety. The consequences:

  - Comparing them directly never matches. `job.location.countries.includes(job.company.hq.country)`
    is always false.
  - The [`country` filter](/docs/guides/filtering#locations) on `/v1/jobs`
    accepts either form — `country=US` and `country=United States`
    both work — but it filters the **job's location**, never the company's HQ.
  - HQ is filterable too, separately: `country=DE` on
    [`/v1/companies`](/docs/api-reference/companies-list), or
    `company_country=DE` on a jobs query.

  Keep a mapping if you need to relate the two field *values*, and normalise at
  your boundary rather than at each comparison.
</Warning>

## `data_tier` has 10 values, not 3

`data_tier` records where a firmographic record came from, which is a proxy for
how complete it is. It's documented as three values in a quality order:
`remco` > `linkedin` > `free`.

<Warning title="Ten values are live. Filtering to the documented three misses ~2,900 records.">
  | Tier | Records | Documented? |
  | --- | --- | --- |
  | `free` | 75,880 | Yes |
  | `linkedin` | 67,357 | Yes |
  | `remco` | 12,392 | Yes |
  | `bidirectional` | 1,566 | **No** |
  | `sibling` | 467 | **No** |
  | `site-declared` | 349 | **No** |
  | `linkedin-fresh` | 260 | **No** |
  | `bidirectional-variant` | 122 | **No** |
  | `site-declared-mismatch` | 107 | **No** |
  | `site-declared-noweb` | 45 | **No** |

  An allowlist of the three documented values drops roughly 2,900 real records
  with no error and no signal that anything was dropped — including
  `linkedin-fresh`, which is a *better* record than plain `linkedin`, not a worse
  one.

  **Treat `data_tier` as an open set.** If you're filtering on quality, exclude
  the tiers you don't want rather than allowlisting the ones you do, and check
  the field you actually care about instead:

  ```js
  // Fragile — silently drops 7 live tiers.
  const good = c.data_tier === "remco" || c.data_tier === "linkedin";

  // Better — asks the real question.
  const usable = c.employee_count != null && c.industry != null;
  ```
</Warning>

## Firmographic filtering, three ways

The firmographics are filterable **server-side**, from two
directions — with the client-side embed pattern as the fallback for anything
the filters don't cover.

### 1. Search companies directly

[`/v1/companies`](/docs/api-reference/companies-list) is a real search now: `q`
(name substring), `industry`, `country` (HQ — full name or ISO-2), `size`,
`employee_count_gte`/`lt`, and `has_jobs=true`, with a `total` and `offset`
paging. When the question is company-first — "which fintech companies in
Germany are hiring?" — ask it directly:

```bash
curl -H "Authorization: Bearer $HYPERJOBS_KEY" \
  "https://api.hyperjobs.io/v1/companies?industry=Financial%20Services&country=DE&has_jobs=true"
```

Every result carries `jobs_count`, so "is hiring" and "how hard" arrive in the
same payload.

### 2. Filter jobs on company attributes

[`/v1/jobs`](/docs/api-reference/jobs-list) grew a company axis:
`company_industry`, `company_size`, `employee_count_gte`/`lt`,
`funding_gte`/`lt`, `company_country`, and `domain`. When the question is
job-first — "remote Rust roles at 50–500-person companies" — the firmographic
predicate goes **into** the jobs query:

```js
const qs = new URLSearchParams({
  skill: "rust",
  work_arrangement: "Remote", // exact and case-sensitive
  employee_count_gte: "50",
  employee_count_lt: "501", // exclusive bound
  limit: "200",
});

const { data, total } = await fetch(`https://api.hyperjobs.io/v1/jobs?${qs}`, {
  headers: { Authorization: `Bearer ${process.env.HYPERJOBS_KEY}` },
}).then((r) => r.json());
```

One request, and `total` counts exactly the set you asked for — the API
applied the size predicate, so there's no page-the-world step to count matches.
(An early draft of this API had no company axis here, which forced a
client-side filter over embeds that `total` couldn't reflect; that whole
workaround never shipped.)

### 3. Filter client-side over the embeds

Still the route for fields with no server-side filter — `founded_year`,
`followers`, `stock`, funding *stage* (only amounts are filterable). The
firmographics arrived with every job, so local predicates cost nothing extra:

```js
const matches = data.filter((job) => {
  const c = job.company;
  // No slug means the minimal fallback — founded_year doesn't exist on it, so
  // this company can't answer the question either way.
  if (!c?.slug) return false;
  return c.founded_year != null && c.founded_year >= 2020;
});
```

<Note>
  When you mix modes, remember what `total` means: it counts what the **API**
  filtered, not what your local predicate kept. To count a client-side-filtered
  set you still have to page the whole thing — see [walking a full result
  set](/docs/pagination#walking-a-full-result-set).
</Note>

### When to call the company endpoints

- **[`/v1/companies`](/docs/api-reference/companies-list)** whenever the
  question is about companies rather than postings — name lookup (`?q=`),
  firmographic search, or building a directory. (An early draft made this a
  browse-only, top-200 endpoint; it isn't one.)
- **[`/v1/companies/{slug}`](/docs/api-reference/companies-retrieve)** when you
  have a slug from somewhere else and want the record without a job attached. It
  matches on `linkedin_slug` and 404s on an unknown one — the only endpoint here
  that 404s on a bad *value* rather than returning empty.

When you just fetched a job, you rarely need either — the embed already carries
the whole firmographic record. (`jobs_count` is the one field only the company
endpoints add.)

## Next

<CardGroup cols={2}>
  <Card title="The Company object" icon="Building2" href="/docs/objects/company">
    The full field reference.
  </Card>
  <Card title="List companies" icon="Users" href="/docs/api-reference/companies-list">
    The company search — every filter, and the response envelope.
  </Card>
  <Card title="Filtering jobs" icon="Funnel" href="/docs/guides/filtering">
    Everything you *can* push server-side.
  </Card>
  <Card title="The Job object" icon="Braces" href="/docs/objects/job">
    Where `company` sits on a posting.
  </Card>
</CardGroup>
