Guides / Company data

Company data

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

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.

Full PublicCompany
has `slug`

The job resolved to a company record. You get every field below. This is 99.9997% of jobs.

Minimal fallback
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.

null

Only when the posting has no organization name at all.

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

The PublicCompany record#

slug
string

The LinkedIn slug, and the primary key. This is what /v1/companies/{slug} takes.

name
string

Display name.

domain
string | null

Primary domain, e.g. figma.com.

website
string | null

Full URL.

logo
string | null

Logo URL.

industry
string | null

Free-text industry label. Not an enum — don't filter on exact equality.

type
string | null

Company type, e.g. privately held, public.

description
string | null

Long-form company description.

slogan
string | null

Tagline.

specialties
string[] | null

Self-declared focus areas.

employee_count
number | null

Headcount as a number. The field to filter on.

size_range
string | null

Bucketed headcount, e.g. 1001-5000. Useful when employee_count is null.

followers
number | null

LinkedIn followers. Also the secondary sort on /v1/companies.

founded_year
number | null

Year founded.

hq
object | null

Headquarters location.

funding
object | null

Funding history.

stock
object | null

Public listing, when there is one.

affiliated_companies
array | null

Related entities — subsidiaries, regional arms.

data_tier
string

Provenance of the record. See below — there are more values than documented.

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

updated_at
string

When the firmographic record was last refreshed.

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 on a job.

Country codes are inconsistent#

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

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.

Ten values are live. Filtering to the documented three misses ~2,900 records.

TierRecordsDocumented?
free75,880Yes
linkedin67,357Yes
remco12,392Yes
bidirectional1,566No
sibling467No
site-declared349No
linkedin-fresh260No
bidirectional-variant122No
site-declared-mismatch107No
site-declared-noweb45No

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;

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 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 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;
});

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.

When to call the company endpoints#

  • /v1/companies 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} 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#

Was this page helpful?