All posts
Engineering

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.

HyperJobs Team8 min read

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:

JSON
{
  "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#

StatusMeaningRetry?
200Success.
400A parameter was unknown, empty, or malformed. The body names it.No — fix the parameter
401Missing, unknown, or revoked API key.No — fix the key
404Unknown job id, company slug, or endpoint path.No
405Non-GET method. The API is read-only.No
429Rate limit or monthly quota exceeded.Yes — honor Retry-After
500Unhandled 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.

  1. Your query string

    however many params, however spelled

  2. Validate every param

    unknown name? empty? bad value?

  3. 400 {error, param, hint}

    the first violation, named

  4. …or run the query

    every filter you sent, applied

One gate, before the query runs. Nothing bad gets past it quietly.

What gets rejected, exactly#

You sendYou get told
?seniorty=Directorunknown parameter — see /openapi.json for the full list
?seniority=seniormust be one of: Internship · Entry level · …
?country=XZ'XZ' is not a known ISO-2 country code
?time_frame=1weekmust be <n><m|h|d>, e.g. 15m, 2h, 7d (m = minutes)
?limit=-1minimum is 1
?limit=0minimum is 1
?cursor=garbageinvalid cursor — pass the previous response's next_cursor verbatim
?title=(rust ORinvalid 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 nameInflated total, looks like a findingNamed in the body, fixed in a minute
Wrong enum valuetotal: 0, looks like "nobody's hiring"The allowed list, in the hint
Unparseable time windowAll-time results, all plausibleThe format, with an example
What your quota boughtAn answer to a different questionAn 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:

  • skill takes any token. skill=Machine Learning is 0 rows — the stored token is machinelearning, and display values don't round-trip.
  • source takes any string. source=Greenhouse is 0 rows — case-sensitive, and the vocabulary lives on /v1/meta, not in your memory.
  • q / organization are 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:

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