DEV Community

Cover image for 13 Keyless Job APIs: A Broken limit Returns 200 Rows, or 0
Alex Spinov
Alex Spinov

Posted on • Originally published at blog.spinov.online

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
Enter fullscreen mode Exit fullscreen mode

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&current=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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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=-1 returns 387 records, 2,422,514 bytes
  • limit=-5 returns 383 records, 2,377,916 bytes
  • limit=not-a-number returns 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
Enter fullscreen mode Exit fullscreen mode

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."}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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. parseInt for Himalayas across eleven values, of which only three actually discriminate between parseInt and Number, 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": null in the envelope argues against a substituted default and for an unconstrained page size.
  • The defect is endpoint-scoped, not site-scoped. /jobs/api/search on 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 3 and 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.

{
"@context": "https://schema.org",
"@type": "ItemList",
"name": "13 Free Job-Board and ATS APIs With No Key (2026)",
"description": "Thirteen job-posting APIs that need no API key, no signup and no card, each checked with a live request on July 29, 2026, together with what each one does when the limit parameter is sent a value that is not a number.",
"itemListOrder": "https://schema.org/ItemListOrderAscending",
"numberOfItems": 13,
"itemListElement": [
{"@type": "ListItem", "position": 1, "name": "Arbeitnow", "url": "https://www.arbeitnow.com/api/job-board-api", "description": "Keyless job-board feed, mainly Germany and Europe. Returned HTTP 200 with 1,544,867 bytes and 175 postings under a data key on July 29, 2026. The limit parameter had no observable effect: limit=3 and limit=not-a-number both returned 175 records with HTTP 200 and near-identical byte counts, so whether the parameter is parsed and discarded or simply absent could not be determined from the response."},
{"@type": "ListItem", "position": 2, "name": "Remotive", "url": "https://remotive.com/api/remote-jobs", "description": "Keyless remote-jobs feed. Returned HTTP 200 with 494,763 bytes and 36 postings under a jobs key on July 29, 2026. The limit parameter had no observable effect: limit=3 and limit=not-a-number returned byte-identical 494,763-byte responses with 36 records."},
{"@type": "ListItem", "position": 3, "name": "RemoteOK", "url": "https://remoteok.com/api", "description": "Keyless remote-jobs feed returning a plain JSON array. Returned HTTP 200 with 441,310 bytes and 101 elements on July 29, 2026, up from 367,408 bytes at the same record count earlier the same day, which illustrates how much the payload size drifts between requests."},
{"@type": "ListItem", "position": 4, "name": "Jobicy", "url": "https://jobicy.com/api/v2/remote-jobs", "description": "Keyless remote-jobs feed using a count parameter rather than limit. The default response returned HTTP 200 with 830,042 bytes and 100 postings on July 29, 2026, while count=3 returned 27,397 bytes and 3 postings. It is one of four providers tested that rejects a non-numeric filter value, returning HTTP 400 with a 67-byte error body. Edge values are handled less cleanly: count=0 and count=-1 each returned one posting rather than none."},
{"@type": "ListItem", "position": 5, "name": "Working Nomads", "url": "https://www.workingnomads.com/api/exposed_jobs/", "description": "Keyless remote-jobs feed returning a plain JSON array. Returned HTTP 200 with 218,212 bytes and 44 postings on July 29, 2026."},
{"@type": "ListItem", "position": 6, "name": "The Muse", "url": "https://www.themuse.com/api/public/jobs?page=1", "description": "Keyless jobs API that requires a page parameter; without it the endpoint returns HTTP 400. With page=1 it returned HTTP 200 with 124,848 bytes and 20 results on July 29, 2026. The limit parameter had no observable effect on top of page: limit=3 and limit=not-a-number both returned 20 results."},
{"@type": "ListItem", "position": 7, "name": "Himalayas", "url": "https://himalayas.app/jobs/api", "description": "Keyless remote-jobs feed. The documentation at himalayas.app/api states 'limit: the number of jobs to retrieve (max 20)' and notes that as of 24th March 2025 the maximum was reduced to 20 jobs per request to improve performance and reliability. On July 29, 2026 every valid numeric value respected that ceiling: limit=20, limit=99999 and no parameter each returned 132,440 bytes and 20 records, limit=3 returned 23,816 bytes and 3 records, limit=0 returned 174 bytes and 0 records, and limit=-1 returned HTTP 500. A non-numeric value returned HTTP 200 with 1,219,458 bytes and 200 records, which is ten times the documented ceiling and 9.2 times the bytes of the documented maximum, reproduced identically on three consecutive repeats. Behaviour across eleven values is consistent with parseInt without validation, most clearly limit=1e9 returning 1 record; of those eleven values only three actually discriminate between parseInt and Number semantics, and limit=0x10 returning 16 records is not one of them, since both rules yield 16. This is inferred from responses rather than read from source. The documentation on the same site also states that the API returns 400 Bad Request for invalid query parameters, which the non-numeric request did not receive. Composed with offset, three requests using the broken value collected 586 unique postings against 60 for three honest requests. A second endpoint on the same site, /jobs/api/search, showed none of this behaviour: five different limit values all returned 93,005 bytes and 18 records."},
{"@type": "ListItem", "position": 8, "name": "Hacker News 'Who is hiring' via the Algolia API", "url": "https://hn.algolia.com/api/v1/search?tags=story&amp;query=who%20is%20hiring", "description": "Keyless search over Hacker News stories and comments, usable to reach the monthly Who-is-hiring threads. Returned HTTP 200 with 197,327 bytes and 20 hits on July 29, 2026. It uses hitsPerPage rather than limit and is one of four providers tested that rejects a non-numeric value, returning HTTP 400 with a 151-byte body."},
{"@type": "ListItem", "position": 9, "name": "Greenhouse public job board API", "url": "https://boards-api.greenhouse.io/v1/boards/stripe/jobs", "description": "The public careers-page backend for one employer at a time, not an aggregator: no key is needed but the URL requires that company's board token. The stripe board returned HTTP 200 with 317,625 bytes and 535 postings on July 29, 2026, having moved from 534 postings earlier the same day. A board token that does not exist returns HTTP 404 in 38 bytes. The limit parameter had no observable effect: limit=3 and limit=not-a-number returned byte-identical responses."},
{"@type": "ListItem", "position": 10, "name": "Ashby public job board API", "url": "https://api.ashbyhq.com/posting-api/job-board/openai", "description": "The public careers-page backend for one employer at a time, keyed by organisation slug rather than an API key. The openai board returned HTTP 200 with 11,855,242 bytes and 739 postings in a single request on July 29, 2026, which is large enough to matter for any job runner with a memory ceiling."},
{"@type": "ListItem", "position": 11, "name": "Lever public postings API", "url": "https://api.lever.co/v0/postings/leverdemo?mode=json", "description": "The public careers-page backend for one employer at a time, keyed by board token. The leverdemo board returned HTTP 200 with 2,427,173 bytes and 388 postings on July 29, 2026, with no ceiling on limit: limit=99999 and no parameter both returned all 388. A non-numeric limit returned HTTP 200 with a 2-byte body and zero records, so an empty result arrives as a successful response. Negative values slice from the end: limit=-1 returned 387 records and limit=-5 returned 383. The pattern reproduced on a second board, matchgroup, where 83 postings became 82 with limit=-1 and 0 with a non-numeric value, indicating the behaviour is in the API rather than in one board's data. Behaviour matches Array.slice(0, N) semantics, inferred from responses rather than read from source. Three tokens tried (netflix, figma, brex) returned HTTP 404."},
{"@type": "ListItem", "position": 12, "name": "SmartRecruiters posting API", "url": "https://api.smartrecruiters.com/v1/companies/Visa/postings", "description": "The public postings feed for one employer at a time, keyed by company identifier. Of five companies tried on July 29, 2026 only Visa returned postings: HTTP 200 with 3,224 bytes and 2 postings. The other four (bosch, Sopra-Steria, McDonalds, Publicis-Groupe) each returned HTTP 200 with a 52-byte body and an empty list. A control run afterwards showed that an invented company identifier returns that same 52-byte body byte for byte, while a malformed path on the same host returns HTTP 404, so these responses cannot distinguish an empty board from a company identifier that does not exist; the likelier explanation is that those four identifiers were wrong. It is one of four providers tested that rejects a non-numeric limit, returning HTTP 400 with an 11-byte body."},
{"@type": "ListItem", "position": 13, "name": "Personio public job search endpoint", "url": "https://ratepay.jobs.personio.de/search.json", "description": "The public careers-page backend for one employer at a time, served from that company's Personio subdomain. The ratepay board returned HTTP 200 with 1,447 bytes and 4 postings on July 29, 2026."}
]
}

{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{"@type": "Question", "name": "Which job-board APIs work with no API key?", "acceptedAnswer": {"@type": "Answer", "text": "Thirteen were checked with live requests on July 29, 2026 and none required a key, a signup or a card: Arbeitnow, Remotive, RemoteOK, Jobicy, Working Nomads, The Muse, Himalayas and the Hacker News Who-is-hiring index via the Algolia API are aggregator feeds, while Greenhouse, Ashby, Lever, SmartRecruiters and Personio serve the public careers page of one employer at a time and need that company's board token in the URL. The EU Open Data portal is keyless too but was excluded, because a search for jobs returns datasets about employment rather than job postings."}},
{"@type": "Question", "name": "Why does a job API return more rows than its documented limit?", "acceptedAnswer": {"@type": "Answer", "text": "Because the value is parsed rather than validated, so the branch that runs when the parse fails is not the branch anyone designed. Himalayas documents limit as max 20 and honoured that for every valid number tested on July 29, 2026, including 99999, while its own documentation promises HTTP 400 for invalid query parameters. Sending limit=not-a-number returned HTTP 200 with 200 records and 1,219,458 bytes instead, which is ten times the documented ceiling and 9.2 times the bytes of a documented-maximum request. Behaviour across the values tested is consistent with parseInt semantics rather than validation, most clearly 1e9 returning 1 record; this is inferred from responses rather than read from source, and why a failed parse lands on exactly 200 records is not established, since the envelope echoes limit: null rather than a substituted default."}},
{"@type": "Question", "name": "Can a broken limit parameter make an API return zero rows with HTTP 200?", "acceptedAnswer": {"@type": "Answer", "text": "Yes, and it is the more dangerous direction because nothing alerts. On Lever's public postings API on July 29, 2026, a board holding 388 postings returned zero records in a 2-byte body with HTTP 200 when limit was a non-numeric value. Negative values sliced from the end instead: limit=-1 returned 387 and limit=-5 returned 383. The same pattern reproduced on a second board, so it is API behaviour rather than one board's data. A pipeline receiving this logs no vacancies today, parses valid JSON, and stays green."}},
{"@type": "Question", "name": "How do I check whether an API honoured my limit parameter?", "acceptedAnswer": {"@type": "Answer", "text": "Three guards, applied in this order. First, validate the value on the client and refuse to send anything that is not a positive integer. Second, cap the number of bytes you read, sized from what your query should plausibly return rather than a round number. Third, compare the number of rows returned against the number requested and fail loudly if it is larger. Measured against both failure modes on July 29, 2026, only the first guard caught both: the row-count check fired on the over-delivering provider and was silent on the provider that returned zero rows, because zero is never greater than the number requested."}},
{"@type": "Question", "name": "Do job APIs return an error when you send a filter value that is not a number?", "acceptedAnswer": {"@type": "Answer", "text": "Usually not. Of ten providers tested on July 29, 2026, four rejected a non-numeric filter value with HTTP 400: Jobicy, SmartRecruiters, the Hacker News Algolia API and the EU Open Data portal. The other six accepted it with HTTP 200. Four of those six (Remotive, Arbeitnow, Greenhouse, The Muse) returned the same record count as for a valid value, so the parameter had no observable effect; for Remotive and Greenhouse the two responses were byte-identical, while for Arbeitnow and The Muse the byte counts differed slightly as the feed drifted between requests. The remaining two changed the size of the response: Himalayas returned ten times its documented maximum and Lever returned nothing at all."}},
{"@type": "Question", "name": "Are Greenhouse, Lever and Ashby job APIs open without a key?", "acceptedAnswer": {"@type": "Answer", "text": "They are open without an API key, but they are not aggregators. Each serves the public careers page of a single employer and the URL requires that employer's board token or organisation slug, so there is no way to browse across companies. Not every token is open: three Lever tokens tried on July 29, 2026 returned HTTP 404, and four of five SmartRecruiters companies tried returned HTTP 200 with an empty list rather than an error. Payloads vary enormously, from 1,447 bytes for one Personio board to 11,855,242 bytes for the Ashby board holding 739 postings."}}
]
}

Top comments (0)