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.
Every jobs API returns a posting with an employer on it. Almost all of them return that employer as a string.
{ "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:
{
"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:
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:
| 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.
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.
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.
Why it is a join and not a search#
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.
Keep reading
The same role posted twice is one job
A role mirrored to Greenhouse and LinkedIn is one posting, not two. What deduplication has to decide, and what the API lets you see.
7 min read
The JSON string that never shipped
employment_type almost went live as JSON text inside JSON. The post explaining why we couldn't fix it is what convinced us to fix it.
6 min read
This API tells you when you're wrong
A bad parameter is a 400 that names it, never a silent drop. Why strict validation is the kindest thing an API can do with your quota.
8 min read