I aggregate job boards. One of the features is "posted in the last 24 hours".
For a week it worked. Then I pulled a full board and asked a simpler question than I usually ask: what does the updated_at distribution actually look like?
GitLab's board, 232 live postings:
distinct updated_at values |
29 |
| jobs sharing one single timestamp |
204 (2026-10-15T15:24:46-04:00) |
earliest first_published on the board |
2026-03-06 |
updated_at on that same job |
2026-10-15 — 222 days later
|
So my "new in the last 24 hours" feed was reporting 204 jobs as fresh. Their real first-published dates span seven months. Every one of them was 222 days old at the most, and one of them was a March posting wearing an October timestamp.
Nothing errored. The API returned 200, the timestamps parsed, the sort worked. The output was just wrong in the way that survives review — plausible, formatted, and committed.
The timestamp is a column about the record, not about the posting
updated_at is a data-quality field. It answers "when did this row last change in our database", and a board that re-syncs its whole catalogue changes every row at once. That's the 204. It is not a bug on Greenhouse's side; it's a field being asked to answer a question it was never about.
The tell is the histogram. Twenty-nine distinct values across 232 rows is not a posting pattern. Postings arrive one at a time — a company does not hire 204 people in the same second. A single value covering 88% of the board is a write operation.
Compare the other two vendors I hit, same question, same day:
| Vendor | Field | Postings | Distinct values |
|---|---|---|---|
Greenhouse (/v1/boards/{slug}/jobs) |
updated_at |
232 | 29 |
Ashby (/posting-api/job-board/{slug}) |
publishedAt |
167 / 814 | 167 / 814 |
Lever (/v0/postings/{slug}?mode=json) |
createdAt |
82 | 82 |
Ashby: 814 postings, 814 distinct timestamps. Lever: 82 and 82. Those two answer "when was this posted" with a per-posting event. Greenhouse answers with a per-row write. Same logical field, opposite reliability — and if you wrote one normaliser and trusted it, you would only be wrong on one third of your sources, which is exactly the amount of wrong that doesn't get noticed.
What actually works
Two fields, one local set.
# DON'T: this is a "board sync" feed, not a "new jobs" feed
new_jobs = [j for j in jobs if is_recent(j["updated_at"])]
# DO: structural date for recency, id for novelty
seen = load_seen_ids() # persisted between runs
first_run = not seen
new_jobs = [
j for j in jobs
if j["id"] not in seen
and (first_run or parse(j["first_published"]) > last_run_at)
]
save_seen_ids(seen | {j["id"] for j in jobs})
first_published is the only Greenhouse field that behaves like a posting date. But it still can't tell you newly new on its own — a job published in March and still open is legitimately in the feed forever. Recency is a property of the posting; novelty is a property of your observation. Those are two different clocks and they need two different fields.
The first_run branch matters more than it looks. On a cold start, every posting is unseen, so the difference set is the whole board. Without that branch you either publish 232 "new" jobs on day one, or you silently drop the board and never recover. I hit both.
This is the same failure as the missing-key trap in the first post of this series — a field that exists, parses, and returns a value that means something other than what you assumed. null at least stops you.
Two more things that cost more than they look
?content=true is a 22× tax. GitLab's board, same 232 jobs either way:
| request | bytes | ratio |
|---|---|---|
/jobs |
163,949 | 1.0× |
/jobs?content=true |
3,646,907 | 22.2× |
/jobs?content=true, gzip |
1,180,797 | 7.2× |
The job count is identical. You get the same 232 records with a content key added. Fetch descriptions on the detail endpoint for the jobs you actually keep, not on the listing that exists to be filtered.
And note the middle column versus the right one: 22× and 7× are the same request. If you benchmark bandwidth with curl --compressed and then reason about your JSON parser's memory, you are off by a factor of three and you will never find it, because both numbers are real and neither is labelled. I have made this mistake in a report; the fix is to write the encoding into the sentence, not the number.
limit can be decorative. Remotive accepts ?limit= and ignores it:
limit=3 -> HTTP 200, 173,720 B, job-count 13
limit=50 -> HTTP 200, 173,720 B, job-count 13
Byte-identical. Both responses are the same 13 jobs. A limit parameter that parses, validates, and does nothing is worse than no parameter — you'll build a paging loop on it and silently stop at whatever the server felt like sending. The only way to know a parameter works is to send two values and compare the counts, not the status codes. Both were 200.
The rule I write down now
For every timestamp I consume, I check the histogram before I check the value:
- Distinct values ≈ row count → it's an event field. Trust it for recency.
- One value covering most rows → it's a write field. It records your vendor's cron, not the world.
That check costs one Counter() and it is the difference between a feed and a random-number generator. The 204 rows were not lying to me. They were answering the question I should have asked.
If you'd rather not maintain three normalisers yourself: I run this as a hosted actor — paste a company, slug or careers URL and get one clean dataset back, built from the Greenhouse, Ashby and Lever job-board APIs. ATS Job Board Aggregator.
Written with AI assistance and verified by hand — every board, count and byte figure above was pulled live on 2026-10-18. The normalisers are in ats_normalizers.py.
Top comments (2)
"Nothing errored. The API returned 200, the timestamps parsed, the sort worked. The output was just wrong" — that's the sentence. It's the same lesson I learned the expensive way with SES: 200 means "accepted," not "delivered," and here 200 means "here are rows," not "these are fresh." The status code describes the server's step, not your situation. The deeper fix is treating first_published and updated_at as answers to different questions and never letting one stand in for the other — a re-sync touching every row is a database event, not a posting event. Did you end up keying the "last 24 hours" feed off first_published, or did you add a staleness heuristic on top of updated_at?
Same thing on Stripe's Greenhouse board right now: 581 of 729 jobs share one
updated_at(2026-10-07T19:02:27-04:00), whilefirst_publishedis distinct for all 729. So it isn't a GitLab quirk, it's how Greenhouse re-syncs.Two more for the normaliser, from the same quick check:
/api/v1/widget/accounts/{slug}) returnspublished_onandcreated_atas plain dates with no time or zone. A "last 24 hours" filter on a date can be off by most of a day, so the seen-id set has to do all the work there.published_aton bunq's board, 100/100 distinctreleasedDateon Bosch's first page.+1 on the
first_runbranch. One related choice: if you never prune the seen set, a job that closes and later reopens under the same id never counts as new again. That's often right, but it's worth deciding on purpose.