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

Source: https://hyperjobs.io/blog/the-api-that-never-says-400

*HyperJobs Team · 9 June 2026 · 8 min read*

<Note>
  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.
</Note>

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

| 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](/openapi.json), and anything that
doesn't hold is rejected before a single row is scanned.

<Flow
  caption="One gate, before the query runs. Nothing bad gets past it quietly."
  steps={[
    { label: "Your query string", note: "however many params, however spelled" },
    { label: "Validate every param", note: "unknown name? empty? bad value?" },
    { label: "400 {error, param, hint}", note: "the first violation, named", accent: true },
    { label: "…or run the query", note: "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](/docs/rate-limits) — 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](/docs/changelog) 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`](/docs/api-reference/meta) and
[the enums reference](/docs/reference/enums) 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](/docs/guides/skills-and-taxonomies).
- **`source`** takes any string. `source=Greenhouse` is `0` rows —
  case-sensitive, and the vocabulary lives on
  [`/v1/meta`](/docs/api-reference/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`](/docs/api-reference/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](/docs/errors); every filter's exact semantics are in
[filtering](/docs/guides/filtering).
