This API tells you when you're wrong
A bad parameter is a 400 that names it, never a silent drop. Why strict validation is the kindest thing an API can do with your quota.
Revised 2026-07-16 for the v1 launch. This post originally ran as "This API never returns a 400", describing a pre-release draft that coerced or dropped bad input. That draft never shipped. v1 inverted the design, and this is the post arguing for the inversion.
You want senior roles, so you send ?seniority=senior. You get:
{
"error": "invalid request",
"param": "seniority",
"hint": "must be one of: Internship · Entry level · Associate · Mid-Senior level · Director · Management"
}A 400, the parameter that caused it, and the six values that would have
worked — one of which is visibly the one you meant. Total time lost: the time
it takes to read a sentence.
That response is the whole design. There is exactly one failure mode for bad input, it is loud, and it names names.
The complete status code list#
| Status | Meaning | Retry? |
|---|---|---|
200 | Success. | — |
400 | A parameter was unknown, empty, or malformed. The body names it. | No — fix the parameter |
401 | Missing, unknown, or revoked API key. | No — fix the key |
404 | Unknown job id, company slug, or endpoint path. | No |
405 | Non-GET method. The API is read-only. | No |
429 | Rate limit or monthly quota exceeded. | Yes — honor Retry-After |
500 | Unhandled server error. | Yes, with backoff |
Read the second row for what it promises. Not "rarely a 400", not "a 400 on some endpoints" — every parameter on every endpoint is validated against the same spec the server executes, and anything that doesn't hold is rejected before a single row is scanned.
Your query string
however many params, however spelled
Validate every param
unknown name? empty? bad value?
400 {error, param, hint}
the first violation, named
…or run the query
every filter you sent, applied
What gets rejected, exactly#
| You send | You get told |
|---|---|
?seniorty=Director | unknown parameter — see /openapi.json for the full list |
?seniority=senior | must be one of: Internship · Entry level · … |
?country=XZ | 'XZ' is not a known ISO-2 country code |
?time_frame=1week | must be <n><m|h|d>, e.g. 15m, 2h, 7d (m = minutes) |
?limit=-1 | minimum is 1 |
?limit=0 | minimum is 1 |
?cursor=garbage | invalid cursor — pass the previous response's next_cursor verbatim |
?title=(rust OR | invalid search syntax on the parameter, with the reason |
Every one of those, in the pre-release draft of this API, was a 200. The
misspelled parameter was ignored and total came back inflated. The wrong
enum ran literally and total came back zero. time_frame=1week was
skipped entirely and quietly returned results from all time. limit=-1 fell
through to a negative SQL LIMIT — which is unlimited — and returned the
dataset.
Two of those failures lie upward, one lies downward, and one mails you six hundred thousand rows. All four wore the same status code as success. That's the design we didn't ship.
A silent drop is a quota-burning trap#
Here's the argument in billing terms, because that's where it lands.
Every authenticated request counts against your rate limit and monthly quota — including the ones that answer a question you didn't ask. A dropped filter doesn't just mislead you; it charges you for the privilege. You paid for "remote senior roles in Germany" and received "every job on earth", and nothing in the response said so. If the consumer of that number is a dashboard, a report, or a decision, the real cost isn't the request — it's everything built downstream on an answer to the wrong question, discovered much later, by someone else.
A 400 costs you one request and one sentence of reading. The trade is not
close.
| Silent drop (the draft) | Strict 400 (shipped) | |
|---|---|---|
| Typo'd parameter name | Inflated total, looks like a finding | Named in the body, fixed in a minute |
| Wrong enum value | total: 0, looks like "nobody's hiring" | The allowed list, in the hint |
| Unparseable time window | All-time results, all plausible | The format, with an example |
| What your quota bought | An answer to a different question | An error you can act on |
The compatibility objection, answered#
The honest defence of leniency was always vocabulary: the
compatibility policy lets new enum values — new
taxonomies, new source values — ship at any time, and a client-side
validator that throws on an unheard-of value turns each addition into a
breakage.
Strict server-side validation doesn't have that problem — it inverts it. The
server validates against the vocabulary it executes, so a new taxonomy is valid
the moment it exists, with no deploy coupling and no stale list anywhere. The
advice that follows is the opposite of what the draft required: don't
maintain a validation table in your client. The server is the validator now.
Read vocabularies from /v1/meta and
the enums reference when you need to display them;
let the 400 catch what's wrong. Your client stays tolerant of new values in
responses — that part of the policy is unchanged — while the request side is
checked by the one component that can't drift.
Matching is still exact and case-sensitive. The difference is that
seniority=mid-senior level is now a correction delivered in the hint, not a
zero delivered as a fact.
What can still be silently zero#
Strictness covers closed vocabularies and formats. Three filters are open by design, and an open filter can't reject a value — it can only fail to match:
skilltakes any token.skill=Machine Learningis0rows — the stored token ismachinelearning, and display values don't round-trip.sourcetakes any string.source=Greenhouseis0rows — case-sensitive, and the vocabulary lives on/v1/meta, not in your memory.q/organizationare substrings. A typo just matches nothing.
When one of those comes back suspiciously empty,
/v1/jobs/count is the cheap way to test a
filter set one change at a time — the old bisection loop, minus most of its
reasons to exist.
The ampersand that eats your query#
One trap validation cannot see, because it happens before the server does.
Eight of the 28 taxonomies carry a spaced ampersand, and & is a query string
separator:
# The `&` must reach the server as %26. --data-urlencode handles the shell too.
curl -G https://api.hyperjobs.io/v1/jobs \
-H "Authorization: Bearer $HYPERJOBS_KEY" \
--data-urlencode "taxonomies=Finance & Accounting"Hand-build that URL and the string ends at the ampersand: taxonomies arrives
holding Finance — which, being a bad enum member, at least gets you a 400
naming it. The filters after the & vanish entirely, and the server can't
warn you about parameters it never received. Finance & Accounting,
Art & Design, Data & Analytics and Customer Service & Support are all
mines. Encode the & as %26, or use a URL builder that does.
Strictness is a trust position#
There's a reading of "never returns a 400" as generosity — the API that always tries its best with what you sent. We wrote that version, and the documentation for it, and the documentation is what changed our minds: page after page of warnings explaining how to detect that your request had been quietly edited. An API that needs a debugging procedure for its own leniency is not being generous. It's outsourcing its parser to every client, one incident at a time.
The shipped position is simpler to state and simpler to trust: if you got a
200, every filter you sent was applied. total means what it says. An
empty result means nothing matched — not "nothing matched what survived". And
when you're wrong, you find out from the API, immediately, with the fix in the
body — not from a dashboard, three weeks later, with the fix in a postmortem.
The full error catalogue, both 429 flavors included, is in
errors; every filter's exact semantics are in
filtering.
Keep reading
The same role posted twice is one job
A role mirrored to Greenhouse and LinkedIn is one posting, not two. What deduplication has to decide, and what the API lets you see.
7 min read
Why every job carries its company
Most jobs APIs hand you an employer name and leave you to resolve it. Here is what changes when the company object is already attached.
6 min read
Every /v1/jobs query is a full scan
Text search got an index. Every other filter still scans — so narrowing returns faster without running faster. Which knobs are connected to anything.
8 min read