Nobody tells us a job is closed
Nobody tells us a job is closed — we notice it is gone from the board. Expiry is a diff, and everything odd about expires_at follows.
No ATS sends us a webhook when a role gets filled. There is no close event, no
status field flipping to closed, no courtesy note. A posting ends its life here
by not being on the board the next time we look.
That is the entire mechanism. Expiry is a diff — and every strange thing
about expires_at falls out of that one decision.
What actually happens#
Every time a board is re-fetched, its current listing is compared against what we hold. Postings that were there last time and aren't there now are marked expired. The employer taking the job down is the signal.
We don't guess. Nothing expires because it has been open ninety days and that feels long, or because the title looks stale, or because a heuristic decided the role was probably filled. Absence from the board is the only evidence we accept, because it is the only evidence the employer actually gave us.
Board re-fetched
hourly, stalest first
Diff vs last sight
what disappeared?
Mark expired
expires_at set
/v1/jobs hides it
expires_at > now
Feed still shows it
so you can delete yours
The upside of a diff is that it is evidence-based and cheap. The downside is the rest of this post.
expires_at is usually null, and that isn't a bug#
Some boards publish an expiry date. When one does, it is honoured — that date
lands in expires_at and the posting disappears from
/v1/jobs when it passes, no diff required.
Most boards publish nothing. So on a perfectly live, perfectly healthy posting,
expires_at is null. That is the common case, not the edge case.
A null expires_at does not mean the job runs forever. It means the board
never said when it ends.
Two genuinely different states share one representation:
expires_at | What it means |
|---|---|
| A date | The board declared an end date. We're passing it on. |
null | The board said nothing. The posting is live until we notice it isn't. |
Anything you build that reads null as "open-ended" is reading a silence as a promise. The field can only report the first row of that table.
Expiry is only as fresh as the board's tier#
A posting is never expired earlier than the next time its board is checked. That is a floor, and the floor is not the same everywhere.
Ingestion runs hourly, and each cycle works the stalest boards first — least recently scraped, in order — so freshness degrades evenly instead of in clumps. Between the tiers, the whole pool of 64,998 boards is covered every few hours. But boards don't all cost the same to fetch, so they don't all get fetched at the same rate:
| Tier | Providers | Cadence |
|---|---|---|
| Fast / direct | greenhouse, ashby, lever, smartrecruiters, personio, teamtailor, breezy and others | Every cycle |
| Heavy | oracle, icims, successfactors, cornerstone, plus sharded workday and bamboohr | Every 3rd cycle |
| Proxy-only | workable, recruitee | Every 3rd cycle |
The fast tier serves clean APIs we can hit directly, so it runs every hour. The heavy and proxy tiers are slow or rate-limited at the source, so they run every third cycle in larger batches — the same throughput, spent less often.
That cadence sets both ends of a posting's life. A new role on a Greenhouse or Ashby board typically appears within the hour; the same role on a Workday or iCIMS board can take several. Removal works identically in reverse: a Greenhouse role that comes down is reaped within the hour, a Workday one lingers.
Don't build anything that assumes uniform latency across boards. If you are computing "postings opened this week" and half your sample is on a third-cycle tier, your week boundary is fuzzier for those boards than for the others — in both directions.
The dark board problem#
Diff-based expiry has one real hole, and it is this one
A board that fails five consecutive times is disabled automatically. That part is correct and necessary: companies delete boards, migrate ATS, and go out of business, and without automatic disabling the rotation fills with corpses and starves the live boards of cycles.
A disabled board's postings don't vanish. They age out through expiry like anything else — which is exactly the problem, because nothing is diffing them any more. There is no fetch, so there is no comparison, so nothing ever marks them expired. The board went dark and stranded its postings in a permanently live-looking state.
This is a genuine weakness, not a footnote. Expiry is entirely parasitic on the
board still answering. When the board stops answering, expiry stops working, and
the rows sit there indefinitely looking every bit as open as a role posted this
morning. That is most of what the long tail back to 2009-12-05 is. Not
history we chose to keep — history nothing was left alive to delete.
There is no clever fix hiding behind this. Expiring a dark board's postings
wholesale would mean guessing, and guessing is the thing the diff exists to
avoid — a board can go dark for a fortnight because of an infrastructure
migration while every one of its roles stays open. The honest position is that
the mechanism has a blind spot, the blind spot has a shape (old rows, dead
boards), and you handle it with a time_frame.
Two endpoints, two populations#
This is the part that damages data rather than merely confusing you.
/v1/jobs | /v1/jobs/feed | |
|---|---|---|
| Duplicates | Excluded | Excluded |
| Expired postings | Excluded — expires_at IS NULL OR expires_at > now | Not excluded — no such filter |
Both endpoints hide duplicates unconditionally. Only the index hides expired
rows. So a mirror that bootstraps from /v1/jobs and then polls the feed is
merging two different populations: the snapshot is live-only, the polls are not.
Ignore expires_at and the mirror accumulates dead jobs indefinitely, while the
bootstrapped rows never expire at all, because nothing ever tells them to.
The documented helper is three lines and there is no reason to write your own:
const isLive = (job) => !job.expires_at || new Date(job.expires_at) > new Date();What you do with the false branch is the whole point. Don't filter those rows
out of your loop — route them to a delete:
for (const job of data) {
if (isLive(job)) store.upsert(job);
else store.expire(job.id); // the feed is how you learn a posting died
}Dropping a non-live row on the floor and upserting the rest leaves your previous copy of it sitting there, live, forever. The row was the notification.
Why the asymmetry is deliberate#
It looks like an oversight. It isn't.
A sync consumer has to see a posting's final state in order to expire its own copy. If the feed hid expired rows, a dying posting would simply stop appearing in your polls — and "stopped appearing" is indistinguishable from "hasn't changed since your last window". You would have no signal at all, only an absence, and you cannot diff against an absence without re-walking the entire index, which is the thing the feed exists to spare you.
So the feed shows you the corpse on purpose. /v1/jobs answers "what can I
search"; the feed answers "what changed, including what died". Different
questions, different populations, and the feed sync
guide has the full loop.
The scale is small, for now: 8,482 expired rows out of 603,335. Small enough to ignore for a week and wake up to a mirror full of jobs nobody can apply to.
A 404 is a delete instruction#
/v1/jobs/{id} returning a 404 on an id
that worked yesterday is normal operation, not an incident. The posting was
reaped, or it was folded into a duplicate — a different mechanism with a
post of its own.
Either way the instruction is the same: treat a 404 as "delete my copy", not
as an error to retry. Retrying it with backoff burns quota to be told the same
true thing repeatedly. If you cache job ids, a 404 is the cheapest, clearest
signal this API gives you about a posting's death — much clearer than the null
expires_at you were staring at.
The archive is not a hiring signal#
The oldest posting in the dataset is from 2009-12-05. It is tempting to read
that range as depth. It isn't. Those rows survive because nothing forced them
out: a board went dark, expiry had nothing to diff against, and the posting has
been sitting there ever since being technically retrievable and practically
fictional.
So default to a time_frame unless you specifically want history:
# Almost always what you want. Without it, you're searching back to 2009.
curl -G https://api.hyperjobs.io/v1/jobs \
-H "Authorization: Bearer $HYPERJOBS_KEY" \
--data-urlencode "time_frame=7d"A recency filter is not a nicety here — it is how you route around a known blind spot in the expiry mechanism using the one thing that blind spot reliably correlates with, which is age.
Expiry, reaping, and the refresh cadence each tier runs on are all in dataset coverage.
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
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.
6 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