# Y Combinator Jobs API

> Query jobs from Y Combinator's Work at a Startup boards through one API with ?source=ycombinator — plus how the platform's data-page attribute really behaves.

Source: https://hyperjobs.io/ats/ycombinator

Y Combinator is not an ATS, and "Work at a Startup" is not a company's own
careers page. It's YC's hiring portal for its portfolio: one board per YC
company at `workatastartup.com/companies/<slug>`, where a founder posts openings
directly.

So a posting here is a third kind of object, and it's worth naming precisely. It
wasn't aggregated the way a [LinkedIn](/ats/linkedin) listing was — the employer
wrote this one and manages the applicants. But it isn't the
[Greenhouse](/ats/greenhouse) or [Ashby](/ats/ashby) board the company runs for
itself either. It's a portal posting, and the apply link goes through a YC
account rather than the employer's own form.

That narrowness is why it's the best-populated source on this site. A YC company
is a known, funded, findable entity, and the portal names it on every page. We
serve 5,273 `ycombinator` postings — a number that moves, so read it from
[`/v1/meta`](/docs/api-reference/meta) rather than trusting this sentence.

## Try it

```bash
curl -G https://api.hyperjobs.io/v1/jobs \
  -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "source=ycombinator" \
  --data-urlencode "has_salary=true" \
  --data-urlencode "limit=2"
```

```json title="Illustrative — the shape, not a live response"
{
  "data": [
    {
      "id": "ycombinator:99318",
      "title": "Software Engineer",
      "company": {
        "slug": "tessellate",
        "name": "Tessellate",
        "domain": "tessellate.io",
        "website": "https://tessellate.io",
        "logo": "https://logo.hyperjobs.io/tessellate.io",
        "industry": "Industrials",
        "employee_count": 58
      },
      "location": {
        "remote": false,
        "countries": ["United States"],
        "cities": ["Los Angeles", "El Segundo"],
        "regions": null,
        "raw": ["Los Angeles, CA, US", "El Segundo, CA, US"]
      },
      "employment_type": ["FULL_TIME"],
      "work_arrangement": "On-site",
      "seniority": null,
      "experience_level": "2-5",
      "salary": {
        "min": 120000,
        "max": 180000,
        "currency": "USD",
        "unit": "YEAR",
        "annual_min": 120000,
        "annual_max": 180000,
        "summary": "$120K - $180K • 0.05% - 0.20% equity"
      },
      "skills": ["angular", "javascript", "react"],
      "taxonomies": ["Software"],
      "visa_sponsorship": false,
      "apply_url": "https://account.ycombinator.com/authenticate?continue=…",
      "url": "https://www.workatastartup.com/jobs/99318",
      "source": "ycombinator",
      "expires_at": null
    }
  ],
  "total": 2,
  "limit": 2,
  "offset": 0
}
```

Two things in that payload are the whole story. `salary.summary` carries the
equity band alongside the cash, because YC asks founders for both. And
`apply_url` is a YC login, not an employer form — see [below](#what-bites-you).

### The fill rates are the reason to care

Measured across the full sweep on 2026-07-16:

| Field | Fill on `ycombinator` |
| --- | --- |
| `description` | 100% |
| Company resolved to a real employer URL | 100% |
| `work_arrangement` | ~100% |
| `visa_sponsorship` | 86% |
| `salary` | 80% |
| Experience | 72% |

<Tip title="80% salary is the highest in the pool">
  Across all sources, [`salary` is populated on 36.0%](/docs/reference/coverage)
  of postings, and `has_salary=true` throws away roughly two thirds of the
  dataset before your other filters apply. On `ycombinator` it throws away a
  fifth. If you're building anything that needs compensation, this source is
  denser than everything else here by a factor of two.
</Tip>

The company row is the other unusual one. Every YC job page names the employer's
real website, so **every `ycombinator` posting reaches a company** — the only
source here where that's true. A [Lever](/ats/lever) board hands you the slug
`northwind` and leaves you to work out who that is; a YC page hands you
`northwind.io` in the same response. See the
[company object](/docs/objects/company).

## How Work at a Startup actually works

It's a Rails app running Inertia.js, and that detail is the entire integration.
Every page embeds its full server-side props as JSON in a `data-page` HTML
attribute. **The HTML page is the API.** There's no JSON-LD anywhere on the site,
and you don't need it.

Two GETs, both HTML:

<CodeGroup>
```bash title="List — the company page"
# props.company.jobs[] — ids and titles, no bodies
curl "https://www.workatastartup.com/companies/<slug>"
```

```bash title="Detail — the job page"
# props.job — descriptionHtml, skills, salary, equity, visa, minExperience
curl "https://www.workatastartup.com/jobs/<id>"
```
</CodeGroup>

`<slug>` is the company slug from `workatastartup.com/companies/<slug>` — for
example `epsilon3-inc`. Decode the attribute's HTML entities, `JSON.parse` the
result, and you have the props the page rendered from. Decode `&amp;` **last**,
or `&amp;quot;` collapses into a quote and takes your parse with it.

<Warning title="Both of those curls return a body-less 406">
  Neither command above works as written, and that's the point of showing them.
  Work at a Startup's load balancer fingerprints TLS. A plain client — curl,
  undici, a script sending a real Chrome user-agent — gets a **406 with no body**,
  the same wall Polymer puts up. A browser-impersonating client (Chrome and
  Firefox profiles both pass) goes straight through with no JS challenge.

  So there's no cheap rung on this ladder. Impersonation isn't an escalation you
  fall back to when the direct fetch starts failing, it's request one. And an
  adapter that returns `[]` when the tier is off doesn't degrade — it hands the
  orchestrator an empty live set and expires every job on a healthy board. It has
  to throw.
</Warning>

### The protocol's own JSON route is walled off

The trap that costs an afternoon. Inertia has a documented way to ask for bare
JSON instead of HTML: send `X-Inertia: true`. It works in a browser. Send it from
a script and you get **the same body-less 406**. Don't reach for the clean JSON
endpoint, scrape the attribute — it carries an identical payload. The route that
looks like the right answer is the one the WAF is watching.

## What bites you

<Warning title="`apply_url` is a YC account wall, not the employer's form">
  On every ATS source here, `apply_url` is the employer's real application form.
  On `ycombinator` it's `account.ycombinator.com/authenticate?continue=…` —
  applying means signing in to Work at a Startup. That's the portal's model, not
  a scraping artifact, and it's the practical difference between this source and
  a company's own board. Check `source` before you assume a URL is an ATS.
</Warning>

**The platform publishes no posting date. Anywhere.** Not on the company page,
not on the job page, not in the props. The home feed carries
`companyLastActiveAt`, which is relative prose ("about 7 hours ago") and is
company-level, not job-level. So `posted_at` on a `ycombinator` row cannot be
YC's publish date, because YC doesn't have one to give — the field
[falls back](/docs/objects/job#posted_at-and-updated_at-fall-back) rather than
going null. Don't read `time_frame=24h` on this source as "posted on YC
yesterday". It isn't.

**Titles carry the company as a suffix.** `Software Engineer (Epsilon3)`. Strip
it only when the parenthesis *is* the company name — `Engineer (Remote)` is a
real title and a blind regex eats it.

**`sponsorsVisa` has three values, not two.** `Will sponsor` is true and
`US citizen/visa only` is false, but `US citizenship/visa not required` is
**neither**. It's a statement about international remote work, not a promise to
sponsor anyone. Mapping it to true invents a commitment the employer never made;
it maps to null.

**Salary and equity are separate strings, and only one is money.** `salaryRange`
is `$120K - $180K`; `equityRange` is `0.25% - 0.75%`. Concatenate them before
parsing and the percentages read as salary figures. Only the cash string goes
through a number parser. The equity survives in the display summary, where it
belongs.

**There is no work-arrangement field.** YC never added one. But its location tags
are ground truth and it labels explicitly: `Hybrid`, `In-person`,
`Remote (US)`. Read arrangement off the tags and fill goes from 35% to about 95%.
One judgment call worth stating: `SF / Remote (US)` is **Remote**, not Hybrid.
YC tags true hybrid when it means it, so an unlabelled office-or-remote posting
is remote-friendly, and calling it Hybrid invents a constraint nobody stated.

**A 404 here is real**, unusually. A dead slug returns YC's static "File Not
Found" page and genuinely means the board is gone. Polymer's identically-shaped
404 is a disguised rate limit; YC's isn't. Treat any *other* non-200 as transient
anyway.

## Or use ours

The fetch is two GETs. Everything around it isn't.

Boards first: there's no index of YC companies that are hiring, but there is an
open dataset, `yc-oss`, whose slugs are the same slugs Work at a Startup uses.
Filtered to `isHiring`, that's **1,497 boards** — the work list nobody hands you.

Then the sweep, measured on 2026-07-16: all 1,497 boards in **156 seconds**,
1,497 of 1,497 OK, **zero blocks and zero 429s** at concurrency 20. YC doesn't
rate-limit, which makes it the rare source where the constraint is purely the
impersonation tier and not a throttle budget. A re-scrape of 150 boards takes 8
seconds and fetches no job pages at all, because the list rows are enough to know
which postings are new.

The part you'd care about most is the one you can't see in a response:
**15% of YC postings are cross-posted to an ATS we already scrape**, and those
[fold into one row](/docs/guides/deduplication) with the employer's own board
winning as canonical. The other **85% are net-new** — roles that exist here and
nowhere else in the dataset. Which also means `?source=ycombinator&limit=1` is
not a count of jobs on Work at a Startup. It's a count of jobs where YC won
dedup, and any breakdown you publish off it is a breakdown of surviving rows.

Query it with `?source=ycombinator`.
[Quickstart](/docs/quickstart), then [pricing](/pricing).
