# Errors

> Every status code the API returns, what causes it, and what to do about it.

Source: https://hyperjobs.io/docs/errors

Errors are JSON with an `error` key. Most carry extra fields with context —
validation errors name the offending parameter and tell you what would have
been accepted.

```json
{
  "error": "invalid request",
  "param": "seniority",
  "hint": "allowed: Internship, Entry level, Associate, Mid-Senior level, Director, Management"
}
```

## Status codes

| Status | Meaning | Retry? |
| --- | --- | --- |
| `200` | Success. | — |
| `400` | Unknown parameter, bad value, or malformed search syntax. | No — fix the request |
| `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` | Per-minute rate limit **or** monthly quota exceeded. | Per-minute: yes, after a pause. Quota: no |
| `500` | Unhandled server error. | Yes, with backoff |

That's the complete list.

## There is a 400 now

<Note title="Strict validation is the feature">
  In a pre-release draft of this API, malformed parameters were silently coerced or dropped, and a typo'd
  filter came back as a plausible-looking `200` — the single biggest source of
  "the API returned the wrong data". That's gone. Every parameter is validated:
  an unknown parameter, a bad value, an empty value, or a malformed cursor is a
  `400` that names the parameter and says what's allowed.

  `?limit=abc` is a `400`, not a silent `limit=50`. `?seniority=senior` is a
  `400` listing the enum, not an empty result set. `?time_frame=lastweek` is a
  `400`, not results from all time. Bad input can no longer masquerade as an
  empty result set — if you got a `200`, every filter you sent was applied.
</Note>

## 400 Bad request

Two flavors, same shape. For an unknown parameter, a bad or empty value, or a
malformed `cursor`:

```json
{ "error": "invalid request", "param": "limit", "hint": "minimum is 1" }
```

For malformed search syntax in `title`, `description`, or their `_advanced`
variants — an unbalanced quote, a dangling operator:

```json
{ "error": "invalid search syntax", "param": "title", "hint": "…" }
```

`param` is always the query parameter to fix; `hint` says why it was rejected —
the allowed enum values, the numeric bounds, the expected format. When the hint
isn't enough, [`/openapi.json`](https://api.hyperjobs.io/openapi.json) is the
generated spec with every parameter's exact constraints, no key needed.

A `400` will never succeed on retry. Fix the request.

## 401 Unauthorized

```json
{ "error": "invalid or missing api key", "hint": "Authorization: Bearer <key>" }
```

Returned when the key is absent, unknown, or revoked. The response deliberately
doesn't distinguish between those cases.

`401` responses carry no `X-RateLimit-*` headers — those only appear once a
request has authenticated. See [rate limits](/docs/rate-limits).

## 404 Not found

Two different shapes. For an unknown resource:

```json
{ "error": "not found" }
```

For an unknown endpoint path:

```json
{
  "error": "unknown endpoint",
  "see": "/v1/jobs · /v1/jobs/count · /v1/jobs/feed · /v1/jobs/expired · /v1/jobs/modified · /v1/jobs/:id · /v1/companies · /v1/companies/:slug · /v1/meta · /v1/health · /openapi.json"
}
```

A `404` on `/v1/jobs/{id}` means the posting isn't in the dataset — most often
because it expired and was reaped, or because it was folded into another
posting as a duplicate. See [duplicates](/docs/guides/deduplication).

## 405 Method not allowed

```json
{ "error": "method not allowed — the API is read-only (GET)" }
```

Any `POST`, `PUT`, `PATCH`, or `DELETE`. There are no write routes; every
endpoint is a `GET`. (`OPTIONS` is the one exception — it's answered with a
`204` for CORS preflight.)

## 429 Rate limited

Two distinct limits produce a `429`, with different bodies and different
correct responses.

### Per-minute

```json
{ "error": "rate limit exceeded", "rate_per_min": 300, "retry_after_s": 12 }
```

`rate_per_min` is the ceiling you hit; `retry_after_s` — also sent as a
standard `Retry-After` header — is how many seconds until the sliding
60-second window frees up. Wait that long and retry; if you're hitting this
regularly, pace the client with the `X-RateLimit-*` headers instead — see
[rate limits](/docs/rate-limits#staying-under-the-limit).

### Monthly quota

```json
{
  "error": "monthly quota exceeded",
  "monthly_quota": 50000,
  "resets": "first of next month (UTC)"
}
```

No `Retry-After` header — retrying won't help until the quota resets on the
first of the next month (UTC) or you upgrade. Distinguish the two flavors by
the `error` string or by the presence of `Retry-After`.

## 500 Server error

```json
{ "error": "internal", "message": "…" }
```

Ours, not yours. Retry with exponential backoff and jitter. If it persists,
send the `message` to [support](/docs/support) — it's the server's own exception
text and usually identifies the problem immediately.

## Handling errors well

A client that's correct against this API needs to do three things:

<Steps>
  <Step title="Retry per-minute 429 and 5xx, nothing else">
    `400`, `401`, and `404` will never succeed on retry, and neither will a
    quota `429` — retrying them just burns quota. Tell the two `429`s apart by
    the `Retry-After` header: present means wait and retry, absent means stop.
  </Step>
  <Step title="Honor Retry-After, back off 5xx with jitter">
    ```js
    async function request(url, key, attempt = 0) {
      const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
      if (res.ok) return res.json();

      const retryAfter = res.headers.get("Retry-After");
      // Retryable: 5xx, or a per-minute 429 (quota 429s send no Retry-After).
      const retryable = res.status >= 500 || (res.status === 429 && retryAfter);
      if (!retryable || attempt >= 5) {
        throw new Error(`${res.status}: ${JSON.stringify(await res.json())}`);
      }

      // The server tells you exactly how long the sliding window needs. Jitter
      // keeps a fleet of workers from retrying in lockstep and re-triggering it.
      const base = retryAfter ? Number(retryAfter) * 1_000 : 500 * 2 ** attempt;
      await new Promise((r) => setTimeout(r, base + Math.random() * 500));
      return request(url, key, attempt + 1);
    }
    ```
  </Step>
  <Step title="Treat 400 as a bug report, not a transient">
    A `400` names the parameter and says what was expected — surface `param`
    and `hint` in your logs verbatim rather than swallowing them into a generic
    failure. Enum-valued filters like `seniority` and `work_arrangement` are
    exact and case-sensitive; the hint lists the vocabulary, and the
    [enums reference](/docs/reference/enums) has it all in one place.
  </Step>
</Steps>
