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

Source: https://hyperjobs.io/blog/how-a-posting-expires

*HyperJobs Team · 5 May 2026 · 9 min read*

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.

<Flow
  caption="A posting's death, end to end. Note the last step — the feed is deliberately still showing you a corpse."
  steps={[
    { label: "Board re-fetched", note: "hourly, stalest first" },
    { label: "Diff vs last sight", note: "what disappeared?" },
    { label: "Mark expired", note: "expires_at set" },
    { label: "/v1/jobs hides it", note: "expires_at > now" },
    { label: "Feed still shows it", note: "so you can delete yours", accent: true },
  ]}
/>

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`](/docs/objects/job) 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

<Warning title="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.
</Warning>

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`](/docs/api-reference/jobs-list) | [`/v1/jobs/feed`](/docs/api-reference/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:

```js
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**:

```js
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](/docs/guides/feed-sync) 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}`](/docs/api-reference/jobs-retrieve) 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](/blog/collapsing-duplicate-postings).

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:

```bash
# 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](/docs/reference/coverage).
