13 Keyless Job APIs: A Broken `limit` Returns 200 Rows, or 0
Two keyless job APIs. The same broken value in the same parameter. July 29, 2026:
himalayas.app ?limit=not-a-number HTTP 200 1219458 B 200 records
api.lever.co &limit=not-a-number HTTP 200 2 B 0 records
Himalayas documents that parameter as max 20. Lever’s board has 388 postings on it.
Neither response has an error key, a warning field, or a non-200 status. Lever’s body is a bare []: two bytes, with nothing in it to branch on. Himalayas does leave one signal, and I walked straight past it on the first pass: its envelope echoes the applied value back at you, and on the broken request that echo reads "limit": null. One of them handed me ten times the ceiling the provider advertises. The other handed me nothing at all and called it success.
The one idea
A keyless job API returns job postings over plain HTTP with no key, no signup and no card. I checked thirteen of them with live requests on July 29, 2026, and the table is further down.
The thing worth your attention is not which ones are alive. It is what happens when the filter value you send is not a number. On six of the ten providers I tested for it, a broken value is neither rejected nor honoured. On two of those six it removes the limit that was there — once upward, once all the way to zero. On the other four the parameter never did anything in the first place, with a valid value or a broken one, so there was no limit there to remove.
So limit is not a promise the server made you. It is a hint the server may or may not parse, and when the parse fails, whatever the code does next is not a decision anyone designed.
The whole proof, with the controls that had to fail first
Standard library, no key, no account, no card. Copy it and you should get the same shape I did.
#!/usr/bin/env python3
"""Keyless, standard library only, needs network. No key, no signup, no card.
Asks two job APIs for a number of rows, then asks again with one character
changed so the value is no longer a number, and prints what each answer costs.
Controls run first: a probe that cannot report failure is not a probe.
The multiple is taken against the provider's DOCUMENTED maximum (20), not
against whatever small number the caller happened to ask for. Comparing 200
against a request for 3 measures your own request size, not the ceiling.
"""
import json
import urllib.error
import urllib.request
UA = {"User-Agent": "limit-probe (contact: you@example.com)"}
DOCUMENTED_MAX = 20 # himalayas.app/api: "limit: the number of jobs to retrieve (max 20)"
def get(url):
"""Return (http, bytes, records). Failures are returned, never swallowed."""
req = urllib.request.Request(url, headers=UA)
try:
with urllib.request.urlopen(req, timeout=60) as r:
raw, code = r.read(), r.status
except urllib.error.HTTPError as e:
return e.code, len(e.read()), None
except Exception as e:
return "ERR:" + e.__class__.__name__, 0, None
try:
o = json.loads(raw)
except ValueError:
return code, len(raw), None
if isinstance(o, list):
return code, len(raw), len(o)
for k in ("jobs", "data", "results", "hits", "content"):
if isinstance(o.get(k), list):
return code, len(raw), len(o[k])
return code, len(raw), None
def show(label, url):
code, size, n = get(url)
print(" %-36s http=%-5s bytes=%-9s records=%s" % (label, code, size, n))
return size, n
H = "https://himalayas.app/jobs/api?limit=%s"
S = "https://himalayas.app/jobs/api/search?q=engineer&limit=%s"
L = "https://api.lever.co/v0/postings/leverdemo?mode=json&limit=%s"
print("=== CONTROLS: the probe must be able to say 'bad' ===")
show("host that does not exist", "https://api-zzz-not-real-918273.example/x")
show("live host, path that does not exist", "https://himalayas.app/jobs/api-NOPE-zzz-918273")
show("board token that does not exist", "https://api.lever.co/v0/postings/zzz-not-real-918273?mode=json")
show("POSITIVE: keyless, known up, other field",
"https://api.open-meteo.com/v1/forecast?latitude=52.5&longitude=13.4¤t=temperature_2m")
print("\n=== HIMALAYAS /jobs/api (docs: 'limit ... (max 20)') ===")
b_max, n_max = show("limit=20 the documented maximum", H % "20")
show("limit=3 valid, under the maximum", H % "3")
show("limit=99999 valid, over the maximum", H % "99999")
show("(no limit parameter at all)", "https://himalayas.app/jobs/api")
show("limit=0", H % "0")
show("limit=-1", H % "-1")
b_bad, n_bad = show("limit=not-a-number <-- garbage", H % "not-a-number")
if n_max and n_bad:
print("\n documented ceiling ............. %d records" % DOCUMENTED_MAX)
print(" most a VALID value returned .... %d records" % n_max)
print(" what a NON-NUMBER returned ..... %d records = %.0fx the documented ceiling"
% (n_bad, n_bad / DOCUMENTED_MAX))
print(" bytes for that one request ..... %d -> %d = %.1fx" % (b_max, b_bad, b_bad / b_max))
print(" every line above is HTTP 200 except limit=-1.")
print("\n=== SAME SITE, OTHER ENDPOINT /jobs/api/search: no defect here ===")
for v in ["3", "20", "99999", "not-a-number"]:
show("search q=engineer limit=%s" % v, S % v)
show("search q=engineer (no limit)", "https://himalayas.app/jobs/api/search?q=engineer")
print("\n=== LEVER: same broken validation, opposite outcome ===")
show("(no limit parameter)", "https://api.lever.co/v0/postings/leverdemo?mode=json")
for v in ["3", "-1", "-5", "not-a-number"]:
show("limit=%s" % v, L % v)
print("\n=== THE VALUE IS PARSED, NOT VALIDATED ===")
print(" (behaviour consistent with parseInt; I have not seen their source)")
for v in ["3abc", "+5", "20.5", "0x10", "1e9", "abc3", "null", "true"]:
show("Himalayas limit=%s" % v, H % v)
Output on my machine, July 29, 2026, pasted as it came out:
=== CONTROLS: the probe must be able to say 'bad' ===
host that does not exist http=ERR:URLError bytes=0 records=None
live host, path that does not exist http=404 bytes=75803 records=None
board token that does not exist http=404 bytes=41 records=None
POSITIVE: keyless, known up, other field http=200 bytes=316 records=None
=== HIMALAYAS /jobs/api (docs: 'limit ... (max 20)') ===
limit=20 the documented maximum http=200 bytes=132440 records=20
limit=3 valid, under the maximum http=200 bytes=23816 records=3
limit=99999 valid, over the maximum http=200 bytes=132440 records=20
(no limit parameter at all) http=200 bytes=132440 records=20
limit=0 http=200 bytes=174 records=0
limit=-1 http=500 bytes=0 records=None
limit=not-a-number <-- garbage http=200 bytes=1219458 records=200
documented ceiling ............. 20 records
most a VALID value returned .... 20 records
what a NON-NUMBER returned ..... 200 records = 10x the documented ceiling
bytes for that one request ..... 132440 -> 1219458 = 9.2x
every line above is HTTP 200 except limit=-1.
=== SAME SITE, OTHER ENDPOINT /jobs/api/search: no defect here ===
search q=engineer limit=3 http=200 bytes=93005 records=18
search q=engineer limit=20 http=200 bytes=93005 records=18
search q=engineer limit=99999 http=200 bytes=93005 records=18
search q=engineer limit=not-a-number http=200 bytes=93005 records=18
search q=engineer (no limit) http=200 bytes=93005 records=18
=== LEVER: same broken validation, opposite outcome ===
(no limit parameter) http=200 bytes=2427173 records=388
limit=3 http=200 bytes=16368 records=3
limit=-1 http=200 bytes=2422514 records=387
limit=-5 http=200 bytes=2377916 records=383
limit=not-a-number http=200 bytes=2 records=0
=== THE VALUE IS PARSED, NOT VALIDATED ===
(behaviour consistent with parseInt; I have not seen their source)
Himalayas limit=3abc http=200 bytes=23816 records=3
Himalayas limit=+5 http=200 bytes=34113 records=5
Himalayas limit=20.5 http=200 bytes=132440 records=20
Himalayas limit=0x10 http=200 bytes=113864 records=16
Himalayas limit=1e9 http=200 bytes=8878 records=1
Himalayas limit=abc3 http=200 bytes=1219458 records=200
Himalayas limit=null http=200 bytes=1219458 records=200
Himalayas limit=true http=200 bytes=1219458 records=200
I re-ran that whole block before publishing, and 44 of the 45 lines came back identical. The one that moved was the Open-Meteo control, 316 bytes to 315. It reports a live temperature, and the reading lost a character. Every measured line in the job APIs, including all the ones this post rests on, reproduced exactly.
A word about the ratio, because I got it wrong on the first pass and nearly shipped it. My original script compared the 200 records against a request for 3 and printed 66x. That number is arithmetically fine and it measures the wrong thing: how small my own request happened to be. The ceiling being bypassed is 20, so the honest multiple is 10x the records and 9.2x the bytes. If you see 51.2x anywhere in my notes, that is the byte ratio against limit=3, and it says more about me than about the server.
What the provider itself publishes
I fetched the docs page rather than trusting my memory of it. https://himalayas.app/api returned HTTP 200 in 145,866 bytes, and three strings in it matter:
limit: the number of jobs to retrieve (max 20)
As of 24th March 2025, we have reduced the maximum limit to 20 jobs per request to improve performance and reliability.
What error responses does the Himalayas API return? “The API returns 400 Bad Request for invalid query parameters and 429 Too Many Requests when the rate limit is exceeded. Successful requests return 200 with a JSON body containing the jobs array and metadata.”
Read those three lines against the output above. Every valid number I sent respected the ceiling, including 99999, which came back as a quiet 20. The ceiling only moves when the value stops being a number. A limit that was deliberately lowered for performance and reliability comes off with one non-digit character.
The third line is the one that decides what this post is. Without it I would be reporting a gap between a documented maximum and an observed response, and a maintainer could fairly answer that max 20 describes valid input and promises nothing about garbage. That answer is already closed off on the same page: invalid query parameters get 400. I sent an invalid query parameter and got 200 with 200 records. So this is not my expectation being disappointed. It is a published contract missed in two places at once, the status code and the ceiling, and I only have to quote the provider to say so.
The same page also says, on rate limiting: “Due to server capacity constraints, we ratelimit the number of requests that can be made to our API within a certain time period.” No numbers are published. I did not go looking for them, and I say more about that at the end.
Why: the value is parsed, not validated
Look at the last block of output. It is a signature, and it is readable without any access to their code.
| I sent | parseInt would give | Number() would give | records I got |
|---|---|---|---|
3abc | 3 | NaN | 3 |
1e9 | 1, parsing stops at e | 1,000,000,000 | 1 |
20.5 | 20 | 20.5 | 20 |
0x10 | 16, hex prefix | 16, the same | 16 |
+5 | 5 | 5 | 5, but read the note below |
abc3, null, true | NaN | NaN | 200 |
Now be careful about how much that table proves, because I initially credited it with more. Eleven values went out, but only three of them actually separate the two hypotheses: 3abc, 1e9, and weakly 20.5. 0x10 returns 16 under both rules, so it discriminates nothing: a good-looking row carrying no evidence. And the six values that coerce to NaN (abc3, null, true, NaN, Infinity, []) are not six confirmations. They are one observation repeated six times.
One more correction, on +5. A plus in a query string is a space on the wire: new URL("https://x/?limit=+5").searchParams.get("limit") returns " 5". That row tests tolerance of leading whitespace, not of a plus sign. The honest test is %2B5.
What survives the trimming is still the finding: the value is parsed, not validated, and the behaviour is consistent with parseInt over the raw query string with nothing checking the result afterwards. I have not seen their source code. This is a fingerprint taken from outside on the values I tried, and a different implementation with the same coercion rules would leave the same marks.
What I cannot tell you is why NaN produces exactly 200. I called it a fallback in my notes, then found the counter-evidence in my own bytes: the envelope comes back carrying "limit": null, and JSON.stringify({limit: NaN}) is exactly {"limit":null}. The NaN was not swapped for a default somewhere upstream. It travelled all the way to the serialiser. A designed fallback would more plausibly have echoed the default it substituted. Two hundred may simply be the page size of whatever store sits underneath when nothing constrains the query, and the composition test below behaves that way: offset=200 and offset=400 each return a fresh 200. Separately, limit=0 returning 0 rules out the classic parseInt(x) || 200 idiom, because zero is falsy and would have produced 200 instead. There is no NaN check anywhere on this path.
My favourite line in the whole set is limit=1e9. You ask for a billion jobs and you get one, 8,878 bytes, HTTP 200, no comment. If you have ever written limit=1e6 in a hurry because it looked like a big round number, that is a silent single-row response waiting for you.
The same site, the other endpoint, no defect at all
This is the part that keeps the post honest, so it goes above the fold rather than in a footnote.
Himalayas has a second endpoint, /jobs/api/search. I sent it limit=3, limit=20, limit=99999, limit=not-a-number and no limit at all. All five returned 93,005 bytes and 18 records. Byte-identical. The parameter is inert there and the broken value changes nothing.
One site, two endpoints, two different behaviours. So “Himalayas has a limit bug” is too broad a sentence to be true. The accurate one is that /jobs/api has it and /jobs/api/search does not, and if you are going to quote me, quote that.
Lever: the same root, the opposite blast radius
Lever’s public board API is the mirror, and it is the case I would worry about more.
The leverdemo board carries 388 postings. Watch what negative numbers do:
limit=-1returns 387 records, 2,422,514 byteslimit=-5returns 383 records, 2,377,916 byteslimit=not-a-numberreturns 0 records, 2 bytes, HTTP 200
388 minus 1 is 387. 388 minus 5 is 383. That is Array.slice(0, -1) behaviour: a negative end index counts back from the tail. And slice(0, NaN) produces an empty array, which is why garbage returns an empty list rather than a full one. Same caveat as before: this is inferred from the numbers, not read from their code.
I checked whether it was the API or that particular board. The matchgroup board has 83 postings: limit=-1 returned 82, and limit=not-a-number returned 0 in a 2-byte body. Two boards, same pattern, so it lives in the API.
Now sit with the Lever case for a second, because it is the quiet one. Your nightly job sends a limit that a config change turned into an empty string or the literal None. The response is HTTP 200. The JSON parses. The array is valid, and it is empty. Your logs say 0 new postings today, your monitoring stays green, your alerting has nothing to fire on, and the board had 388 jobs on it the whole time.
Himalayas costs you money. Lever costs you the data, and it does it without a single red pixel anywhere.
It composes with offset, and then it is not a curiosity
A single fat response is a curiosity. The thing that changes its character is that the broken value composes with pagination.
Three requests each way, one honest, one broken, counting unique postings by guid:
honest limit=20 offset=0 HTTP 200 bytes=132440 returned=20
honest limit=20 offset=20 HTTP 200 bytes=135479 returned=20
honest limit=20 offset=40 HTTP 200 bytes=129680 returned=20
--> 3 requests, 397599 bytes, 60 UNIQUE jobs
broken limit=not-a-number offset=0 HTTP 200 bytes=1219458 returned=200
broken limit=not-a-number offset=200 HTTP 200 bytes=1275360 returned=200
broken limit=not-a-number offset=400 HTTP 200 bytes=1375889 returned=200
--> 3 requests, 3870707 bytes, 586 UNIQUE jobs
60 against 586 on the same request budget. That is 9.8x.
A rate limiter that counts requests does not see this at all. The requests are identical in number and shape, and each one carries ten times the records, 9.2 times the bytes. Whatever the unpublished request quota is, the effective data ceiling behind it is off by an order of magnitude for anyone who types a non-digit.
Two things I want to be plain about. First, 586 unique out of 600 returned means 14 rows overlapped across the three windows, so the fetch is not perfectly clean. Second, this is not free: the bytes went up 9.7x too, from 397,599 to 3,870,707. You are bypassing a request count, not a bandwidth bill. Which is a decent segue.
Who actually says “that is not a number”?
Ten providers, one question each: send limit=3, then send limit=not-a-number, and see whether the server objects.
| Provider | Parameter | =3 | =not-a-number | Says it is bad? |
|---|---|---|---|---|
| Himalayas | limit | 200 / 23,816 B / 3 | 200 / 1,219,458 B / 200 | no |
| Lever | limit | 200 / 16,368 B / 3 | 200 / 2 B / 0 | no |
| Remotive | limit | 200 / 494,763 B / 36 | 200 / 494,763 B / 36 | no (inert) |
| Arbeitnow | limit | 200 / 1,544,846 B / 175 | 200 / 1,545,554 B / 175 | no (inert) |
| Greenhouse | limit | 200 / 317,625 B / 535 | 200 / 317,625 B / 535 | no (inert) |
| TheMuse | limit | 200 / 124,847 B / 20 | 200 / 124,848 B / 20 | no (inert) |
| Jobicy | count | 200 / 27,397 B / 3 | 400 / 67 B | yes |
| SmartRecruiters | limit | 200 / 3,222 B / 2 | 400 / 11 B | yes |
| HN Algolia | hitsPerPage | 200 / 12,954 B / 3 | 400 / 151 B | yes |
| EU Open Data | limit | 200 / 115,611 B / 3 | 400 / 11 B | yes |
Four of ten reject the value. Six accept it with HTTP 200, and two of those six change the size of what they hand you.
The four inert ones deserve a separate word, because “inert” is a claim I can only half support. Remotive, Arbeitnow, Greenhouse and TheMuse returned the same record count for 3 and for garbage, which tells me the parameter did not take effect. For Remotive and Greenhouse it is stronger: the two responses were byte-identical, same md5 both times. For Arbeitnow and TheMuse the bytes moved a little, 1,544,846 against 1,545,554, while the record count sat still at 175, which is the feed drifting under me between two requests rather than the parameter doing anything. What none of it tells me is whether those four parse the value and discard it or never implemented it at all. I did not separate those, and the response gives me no way to.
One case in the honest camp I got wrong on the first pass, and I would rather correct it here than quietly delete it, because it turned out to be the most instructive row in the table. Jobicy rejects a non-numeric count with a clean 400 and a readable message, while count=0 and count=-1 each return one job rather than zero. I wrote that down as carelessness. Then I read their documentation, which specifies the parameter as range: 1-100, and looked at the body again:
count=0 -> jobCount: 1, appliedFilters: {'count': 1}
count=-1 -> jobCount: 1, appliedFilters: {'count': 1}
count=3 -> jobCount: 3, appliedFilters: {'count': 3}
count=200 -> jobCount: 100, appliedFilters: {'count': 100}
count=not-a-number -> HTTP 400 {"success":false,"error":"The 'count' parameter must be a number."}
The range starts at 1, so a request for 0 is clamped to 1, exactly as written. Out of range at the other end is clamped too, 200 down to 100. And the response says so in a machine-readable field: appliedFilters reports the value the server actually used, so a client can compare what it asked for against what it got without guessing. That is the best behaviour in the entire set of thirteen: reject what you cannot parse, clamp what is out of range, and declare what you did. Nobody ever promised that count=0 returns zero rows. I assumed it. Which is precisely the mistake this post is about, committed by me rather than by a server.
The thirteen, checked live
Every row is a request I made on July 29, 2026 with no key, no account and no card.
| # | Provider | Keyless endpoint | Bytes | Records |
|---|---|---|---|---|
| 1 | Arbeitnow | www.arbeitnow.com/api/job-board-api | 1,544,867 | 175 |
| 2 | Remotive | remotive.com/api/remote-jobs | 494,763 | 36 |
| 3 | RemoteOK | remoteok.com/api | 441,310 | 101 |
| 4 | Jobicy | jobicy.com/api/v2/remote-jobs | 830,042 | 100 |
| 5 | WorkingNomads | www.workingnomads.com/api/exposed_jobs/ | 218,212 | 44 |
| 6 | TheMuse | www.themuse.com/api/public/jobs?page=1 | 124,848 | 20 |
| 7 | Himalayas | himalayas.app/jobs/api | 132,440 | 20 |
| 8 | HN “Who is hiring” via Algolia | hn.algolia.com/api/v1/search?tags=story&query=who%20is%20hiring | 197,327 | 20 |
| 9 | Greenhouse board | boards-api.greenhouse.io/v1/boards/stripe/jobs | 317,625 | 535 |
| 10 | Ashby board | api.ashbyhq.com/posting-api/job-board/openai | 11,855,242 | 739 |
| 11 | Lever board | api.lever.co/v0/postings/leverdemo?mode=json | 2,427,173 | 388 |
| 12 | SmartRecruiters board | api.smartrecruiters.com/v1/companies/Visa/postings | 3,224 | 2 |
| 13 | Personio board | <company>.jobs.personio.de/search.json | 1,447 | 4 |
Rows 9 through 13 are a different kind of thing from rows 1 through 8, and calling them all “job APIs” would be sloppy. Greenhouse, Ashby, Lever, SmartRecruiters and Personio are not aggregators. They are the public careers-page backend of one employer at a time, and the URL needs that employer’s board token. No key, yes. No target, no data.
That token is the whole game. Three Lever tokens I tried (netflix, figma, brex) returned 404 — those boards are not open. SmartRecruiters does something different, and I needed one more control before I could say what. Of five companies I tried, only Visa returned postings; bosch, Sopra-Steria, McDonalds and Publicis-Groupe each returned HTTP 200 with a 52-byte body and an empty list. Here is the control I should have run first, and did not:
ZZQ-DEFINITELY-NOT-A-REAL-COMPANY-918273 -> 200, 52 B, {"offset":0,"limit":100,"totalFound":0,"content":[]}
bosch -> 200, 52 B, byte-for-byte the same body
Visa -> 200, 3,224 B, totalFound 2
a broken path on the same host -> 404, 195 B
A company name I invented on the spot comes back identical to bosch. So my data cannot separate “this board is empty” from “no such company”, and the likelier reading is that four of my five identifiers were simply wrong rather than four employers having zero openings. The claim I can actually defend is narrower, and still worth making: SmartRecruiters answers an unknown company with 200 and an empty list, where the same host manages a perfectly good 404 for a bad path.
Ashby’s OpenAI board is worth one line on its own. 11,855,242 bytes, 739 postings, one request. If your job runner has a memory ceiling, that is not a hypothetical.
For controls: api-zzz-not-real-918273.example failed to resolve, a bogus path on a live host gave 404, a bogus Greenhouse board gave 404 in 38 bytes, and the three key-gated APIs I used as negatives behaved as expected — Adzuna 400, USAJOBS 401, O*NET 401. A keyless endpoint outside this field, Open-Meteo, returned 200. The probe can tell alive from dead from paywalled.
I also checked the EU Open Data portal, and left it out of the thirteen on purpose. Its search endpoint is keyless and answers cleanly, but q=jobs returns datasets about employment, with titles like “Workforce Jobs” and “Current Job Vacancies”, not the vacancies themselves. It appears in the validation table above because it does reject a broken limit. It is not a job board and I am not going to pad a list with it.
How this is different from three things I already wrote
I have circled this neighbourhood before, and the differences are the reason this post exists.
In 11 keyless package-registry APIs, and why you can’t pin latest the experiment varied the provider while the input stayed correct: five registries, latest spelled properly every time, five different answers because five humans made five editorial decisions. Here the provider is held still and the input is what varies. That post also admitted, in as many words, that it could not demonstrate its own mechanism inside one afternoon. This one fires on demand, in one request, and three consecutive repeats returned the identical md5 for both the honest and the broken response.
Your scraper collected 50 rows. There were 4,000. is about undercount discovered by fingerprinting repeated pages during a crawl, and its code is a mock with no network in it at all. This is overcount from a single live request, plus an undercount that arrives by a completely different road: not a page cap during a walk, but one character in one value.
You pay for the bandwidth that returns nothing put a price on wasted traffic, and said plainly that its figures were a model built on published proxy prices rather than a measured invoice. The 1,219,458 bytes above are not modelled. A request that should have been capped at 20 rows delivered 200, and I have the byte counts for both — including the detail that the request which cost that much did not ask for a number at all.
What an agent does with 1.2 MB it did not ask for
If a tool call in an agent loop wraps that endpoint, the response goes into a context window, and bytes become tokens.
I encoded all three responses with tiktoken (cl100k_base, not standard library, so this is a number I am reporting rather than one the script above produces):
limit=3 23,816 B -> 5,531 tokens
limit=20 132,440 B -> 32,928 tokens
limit=not-a-number 1,219,458 B -> 302,119 tokens
302,119 tokens from one tool call, because someone’s config turned a number into a string. That is 9.2x the documented maximum’s worth and it arrives with HTTP 200. Note the size of it: 302k tokens is past the context window of most models in use today, so the realistic outcome is not a quietly degraded run but a hard failure on the tool result. Where it does fit, it fits by evicting everything else you had in the window. I measured a related version of this problem in feeding raw HTML to your LLM is a token tax; this one is cheaper to trigger, because it does not need a bad tool, only a bad value.
The fix, and which check actually catches both
Three guards. Only one of them catches both failure modes, which was not obvious to me until I ran it.
#!/usr/bin/env python3
"""The guard. Standard library only, needs network, no key.
Point of the last block: only ONE of the three checks catches BOTH failures."""
import json, urllib.error, urllib.request
UA = {"User-Agent": "limit-probe (contact: you@example.com)"}
class FilterIgnored(Exception):
pass
def fetch_rows(base, param, want, key=None, cap_bytes=2_000_000):
# CHECK 1. Refuse to send a value the server would have to guess at.
# bool is checked FIRST and separately: isinstance(True, int) is True in
# Python, so without that clause `want=True` sails through and goes on the
# wire as limit=1. YAML turns `limit: yes` into True, which is exactly the
# kind of config accident this whole post is about.
if isinstance(want, bool) or not isinstance(want, int) or want < 1:
raise ValueError("%s must be a positive int, got %r" % (param, want))
sep = "&" if "?" in base else "?"
req = urllib.request.Request("%s%s%s=%d" % (base, sep, param, want), headers=UA)
with urllib.request.urlopen(req, timeout=60) as r:
# CHECK 2. Stop reading before an unbounded body becomes your problem.
body = r.read(cap_bytes + 1)
if len(body) > cap_bytes:
raise FilterIgnored("body passed %d bytes with %s=%d" % (cap_bytes, param, want))
o = json.loads(body)
rows = o if isinstance(o, list) else o[key]
# CHECK 3. The filter is a request, not a promise.
if len(rows) > want:
raise FilterIgnored("asked for %d, server sent %d" % (want, len(rows)))
return rows, len(body)
HIM = ("https://himalayas.app/jobs/api", "limit", "jobs")
LEV = ("https://api.lever.co/v0/postings/leverdemo?mode=json", "limit", None)
def unguarded(base, param, value, key):
"""What a normal client does: send the value, trust the 200."""
sep = "&" if "?" in base else "?"
req = urllib.request.Request("%s%s%s=%s" % (base, sep, param, value), headers=UA)
with urllib.request.urlopen(req, timeout=60) as r:
body = r.read()
o = json.loads(body)
return (o if isinstance(o, list) else o[key]), len(body)
print("A. THE GUARD ON GOOD INPUT")
for base, param, key in (HIM, LEV):
rows, n = fetch_rows(base, param, 20, key=key)
print(" %-24s limit=20 -> %3d rows, %8d bytes" % (base.split("/")[2], len(rows), n))
print("\nB. WHAT AN UNGUARDED CLIENT GETS FROM ONE NON-NUMERIC VALUE")
for base, param, key in (HIM, LEV):
rows, n = unguarded(base, param, "not-a-number", key)
print(" %-24s limit=not-a-number -> %3d rows, %8d bytes, HTTP 200"
% (base.split("/")[2], len(rows), n))
print("\nC. WHICH CHECK CATCHES WHICH FAILURE")
for base, param, key in (HIM, LEV):
host = base.split("/")[2]
rows, n = unguarded(base, param, "not-a-number", key)
c3 = "FIRES" if len(rows) > 20 else "silent"
c2 = "FIRES" if n > 2_000_000 else "silent"
try:
fetch_rows(base, param, "not-a-number", key=key)
c1 = "silent"
except ValueError:
c1 = "FIRES"
print(" %-24s check1 validate=%-6s check2 body cap=%-6s check3 count>want=%s"
% (host, c1, c2, c3))
Output, same machine, July 29, 2026:
A. THE GUARD ON GOOD INPUT
himalayas.app limit=20 -> 20 rows, 132440 bytes
api.lever.co limit=20 -> 20 rows, 147089 bytes
B. WHAT AN UNGUARDED CLIENT GETS FROM ONE NON-NUMERIC VALUE
himalayas.app limit=not-a-number -> 200 rows, 1219458 bytes, HTTP 200
api.lever.co limit=not-a-number -> 0 rows, 2 bytes, HTTP 200
C. WHICH CHECK CATCHES WHICH FAILURE
himalayas.app check1 validate=FIRES check2 body cap=silent check3 count>want=FIRES
api.lever.co check1 validate=FIRES check2 body cap=silent check3 count>want=silent
Block C is the one to keep. The count check, len(rows) > want, is the guard everybody reaches for, and it catches Himalayas cleanly. Against Lever it is silent, because zero rows is not more than twenty and never will be. Every response-side check has this shape: it can only see the direction it was written to see.
The 2 MB body cap is silent on both, and that is my own default being too generous rather than the check being wrong. Himalayas returned 1,219,458 bytes, comfortably under it. Set the cap from what your query should plausibly return, not from a round number you like the look of. Twenty job postings are about 132 kB here, so 2 MB was never a meaningful ceiling for this call.
Only check 1 fires on both, and it is the cheapest of the three: refuse to put a value on the wire that is not the type you meant. No network, no allocation, no ambiguity. You cannot be surprised by a coercion that never happened. Note the bool clause in it: isinstance(True, int) is True in Python, so without that line a config file that says limit: yes passes validation and goes out as limit=1. I wrote the check without it first.
What all three are blind to sits in my own output two blocks up: limit=99999 comes back as 20 rows, HTTP 200. Check 1 passes it, because 99999 is a positive integer. Check 2 sees 132 kB. Check 3 asks whether 20 is greater than 99999 and stays quiet. Silent under-delivery of a perfectly valid request slips past the entire set. So this is not the whole prescription. It covers coercion accidents, and it is worth having for that. The direction it misses is the same one Lever demonstrates, which is why the last section of this post is an open question rather than an answer.
Honest limits
- This is a snapshot. Thirteen endpoints, ten validation probes, one machine, one IP, one day: July 29, 2026. Three consecutive repeats of the Himalayas pair returned identical md5s, but those repeats span minutes, not weeks. Whether the defect survives next Tuesday I do not know, and if you are reading this later, run the probe before quoting me.
- The mechanism is inferred from behaviour, not read from source.
parseIntfor Himalayas across eleven values, of which only three actually discriminate betweenparseIntandNumber, and six are one NaN observation repeated.slice(0, N)for Lever across four values plus a second board. Both fit every observation I have. Neither is a code review, and I would drop both labels before I would drop the measurements. Why a failed parse lands on exactly 200 I do not know:"limit": nullin the envelope argues against a substituted default and for an unconstrained page size. - The defect is endpoint-scoped, not site-scoped.
/jobs/api/searchon the same domain has none of it. One endpoint of one site is a much smaller claim than the headline implies, and the headline is doing headline work. - Five of the thirteen are employer boards, not aggregators. Greenhouse, Ashby, Lever, SmartRecruiters and Personio each serve one company at a time and need that company’s board token. Four of five SmartRecruiters companies I tried returned an empty list with HTTP 200. And since a company name I made up returns that same 52-byte body, I cannot tell whether those four boards are empty or those four identifiers are wrong. I lean towards wrong identifiers.
- I did not measure rate limits, deliberately. Himalayas says it rate-limits and publishes no numbers. Finding the threshold means hammering somebody’s server to make a point, so there is not a single rate-limit figure in this post. The 9.8x above is a ratio between two 3-request runs, not a claim about any quota.
- I did not test whether 200 is itself a ceiling. Every non-numeric value I tried returned exactly 200 records. Whether something gets past that, I never checked.
- Four providers are labelled “inert” on thin evidence. Same bytes for
3and for garbage proves the parameter had no effect. It does not prove whether it is parsed and ignored or absent from the API, and I did not separate those. - Live data drifts. Between the first pass and the final one, Greenhouse’s Stripe board moved 534 to 535 postings and RemoteOK’s payload moved from 367,408 to 441,310 bytes at a steady 101 records. Expect your byte counts to differ from mine by a few percent.
- No production telemetry backs this one. I run scrapers and data pipelines for a living, 32 published actors and something over 2,000 runs by my own count, and that experience is why I probe filter parameters before trusting them. It is not a source of numbers here: job boards are a field I have no production history in, so every figure above comes from this one sitting and nowhere else.
- Nothing here is an invitation to hammer anyone. The composition test was three requests. I am describing a defect so you can defend your own pipeline against it, not handing out a scraping technique.
The open question
Here is the part I have not solved, and it is the Lever direction, not the Himalayas one.
The over-delivery is easy. len(rows) > want catches it, costs nothing, and fires the first time. The zero case has no such tell. An empty array is a completely legitimate answer — a board really can have no open roles today, a filter really can match nothing — and the response is byte-identical whether that is true or the parameter silently poisoned the query.
The only detector I have is a remembered baseline: this board had 388 yesterday, it has 0 today, that is worth an alert. It works, and I do not like it. It needs state, it needs a warm-up period before it can say anything, it goes wrong every time a source legitimately empties out, and on a new source it is useless on day one, which is exactly the day you are most likely to have your parameters wrong.
So: if a source can legitimately return zero rows, how do you tell a real zero from a poisoned filter on the first request, before you have any history to compare against? I have not found an answer that does not reduce to “send a second request you know the shape of”, and I would like a better one. Tell me in the comments, I read every one. 👇
Written with AI assistance. Every status code, byte count, record count, token count and command output above comes from my own live requests and negative controls on July 29, 2026, printed as they came out.