# Errors

Every status the API emits, its JSON shape, whether it is worth retrying, and what to do about it. Two of them are routinely confused and it is expensive.

**What this covers:** The shape of an error; Every status code; 402 — the licence, not the throttle; 422 — a value we will not guess at; A retry policy that is correct.

**Assumed knowledge:** [Authentication](./api-authentication.md).

**Canonical HTML:** https://jobopportunitiesapi.org/docs/api/errors  
**Machine-readable index:** https://jobopportunitiesapi.org/docs/ai/index.md  
**Last verified:** 2026-08-22  
**Superseded by:** the live API at https://api.jobopportunitiesapi.org and its spec at https://jobopportunitiesapi.org/openapi.json — where this file and the API disagree, the API is right.

---

<a id="error-shape"></a>

## 1. The shape of an error

Always JSON, always three fields: a stable error code to branch on, a human message, and a docs link.

### 1.1 In depth

Every error body, without exception

```json
{
  "error": "bad_provider",
  "message": "Unknown provider \"nosuch\". The list we publish is at /public/providers …",
  "docs": "https://jobopportunitiesapi.org/api"
}
```

`error` is the stable machine code — branch on this. `message` is written for a human and will change wording without notice; it names the specific value that was wrong, which makes it worth logging but not worth matching on. `docs` is a link.

Some errors add a field. A 503 timeout carries `retry_after` in the body as well as in the header, so a client that reads JSON and ignores headers still gets the number. A mid-stream export failure carries the resume cursor.

### 1.2 Exact contract

The database error string is never published. A failed read logs the real error server-side and returns a fixed message, because a Postgres error on an unauthenticated endpoint tells a reader about the schema.


| field | type | always present | allowed values | meaning |
| --- | --- | --- | --- | --- |
| docs | string | no | — | — |
| error | string | no | — | — |
| message | string | no | — | — |

**See also**

- [Every status code](./api-errors.md#error-table)
- [A retry policy that is correct](./api-errors.md#retry-policy)

<a id="error-table"></a>

## 2. Every status code

Nine statuses, each with one meaning. The generated table below comes from the specification; the notes underneath are the parts a spec cannot carry.

### 2.1 In depth

| code | meaning |
| --- | --- |
| 200 | A page of listings. |
| 401 | Missing, unknown, revoked or expired key. All four are reported identically. |
| 402 | The plan's monthly RECORD allowance is spent. Distinct from 429: a 429 means slow down and retry, a 402 means the licence is used up until the 1st of the month. Do not retry. Every metered response carries `X-RateLimit-Records-Remaining`, so this is visible long before it happens. |
| 422 | A parameter we will not guess at: an unknown source_type or provider, an unparseable timestamp, a malformed cursor. Note the deliberate exceptions, which are clamps rather than errors: `limit` is capped at the plan maximum (a non-numeric value falls back to the default), and comma-separated lists are truncated to the per-parameter maximum shown on each. Unknown query parameters are ignored. |
| 429 | Over your plan's per-minute or per-day limit. `Retry-After` says how long. |

| Status | `error` | Retry? | Cause and cure |
| --- | --- | --- | --- |
| **400** | varies | No | A malformed request — usually a parameter that cannot be parsed at all. |
| **401** | `unauthorized` | No | Missing, unknown, revoked or expired key. All four are identical by design. [Detail](./api-authentication.md#auth-failure-modes). |
| **402** | `record_quota_exhausted` | **No** | The month's record allowance is spent. [Detail](#error-402). |
| **402** | `key_required_to_page` | No | A `cursor` on a keyless request. Take a free key. [Detail](./api-keyless.md#keyless-limits). |
| **403** | `upgrade_required` | No | The endpoint is not in your plan. `X-JOA-Required-Feature` names what is missing. |
| **301** | — | No | A company slug that has been retired. `Location` carries the current slug — follow it (`curl -L`). Never cached. |
| **404** | `not_found` | No* | No such row. *Except a company slug, which can move — [re-resolve rather than delete](./endpoints-companies.md#slug-instability). |
| **422** | several | No | A value the API will not guess at. The body names it. [Detail](#error-422). |
| **429** | `rate_limited` | Yes | Over the per-minute or per-day request limit. Wait `Retry-After`. |
| **500** | `query_failed` | No | Something is broken. Retrying will not help. Please report it. |
| **503** | `timeout` | Yes | The query ran past its time limit. Retry in ~5s and narrow the filter. |
| **503** | `unavailable` | Yes | A dependency was briefly unreachable — usually during a deploy. |

### 2.2 Exact contract

A note on 503 that is worth reading if you have automation. Every read path used to return 500 for any database error, including the most common one — the ten-second statement timeout. Those are not the same event: a 500 tells a client to stop permanently, and a timeout is usually transient and load-dependent. Timeouts are now 503 with `Retry-After: 5` and `retry_after: 5` in the body, and a 500 deliberately carries no `Retry-After` at all.

This matters more over time rather than less: the corpus is growing into that timeout. A published example measured 3.15 seconds against a ten-second ceiling in August 2026, and the same query shape had been recorded at 10.09 seconds days earlier under load. The behaviour when it trips had to be correct before it trips.

> **A failed response is never cached (fixed 2026-08-23)** — **A retry after a 503 now always reaches the API.** The header is set at write time and chosen by status: a 2xx keeps its `s-maxage`, and anything else — 4xx, 5xx, a timeout — is sent as `Cache-Control: no-store`. Redirects included. You do not need to vary the query or inspect `cf-cache-status` to force a real retry any more.

For the record, because integrations written before this date may still carry the workaround: until 2026-08-23 a timed-out `/public/jobs` answered 503 carrying `Cache-Control: public, max-age=60, s-maxage=300`, so an identical retry could re-read the cached failure for up to five minutes. `/v1/*` was never cached and was never affected. The workaround is harmless to leave in place, but it is no longer doing anything.

**See also**

- [402 — the licence, not the throttle](./api-errors.md#error-402)
- [422 — a value we will not guess at](./api-errors.md#error-422)
- [Backing off correctly](./api-rate-limits.md#backoff)

<a id="error-402"></a>

## 3. 402 — the licence, not the throttle

Your plan's monthly record allowance is spent. Retrying will not help until the first of the month. This is the error most often handled wrongly.

### 3.1 In depth

A 429 says “slow down”. A 402 says “the licence is used up”. A client that treats 402 as a rate limit will back off, retry, back off, retry — for up to three weeks, generating load and getting nothing, and usually without anybody noticing because the logs look like ordinary throttling.

It should never be a surprise. Every metered response carries `X-RateLimit-Records-Remaining`, so the wall is visible from a long way off. Alert on that header falling below a threshold rather than on the 402 arriving.

The 402 body

```json
{
  "error": "record_quota_exhausted",
  "message": "This plan's monthly record allowance is used up. It resets on the 1st. Move up a plan: https://jobopportunitiesapi.org/dashboard#upgrade",
  "docs": "https://jobopportunitiesapi.org/api"
}
```

### 3.2 Exact contract

There is a second, unrelated 402 with the code `key_required_to_page`: a `cursor` sent to a keyless `/public/*` endpoint. It is a licence boundary too, which is why it shares the status, but the cure is different — take a key. Branch on `error`, not on the status.

One special case in bulk export: if the allowance runs out **mid-stream**, the response has already begun with a 200 and cannot become a 402. The last line of the NDJSON body is then an object carrying `error: "record_quota_exhausted"` and the `after` cursor to resume from. A consumer must check the final line rather than assuming a clean end of stream. [Export detail](./endpoints-export.md#export-quota).

Country-locked keys are exempt from the record meter for job rows and report `X-RateLimit-Records-Limit: unlimited`. They cannot hit this error on `/v1/jobs`. See [country-locked keys](./account-record-meter.md#country-locked).

**See also**

- [Running out](./account-record-meter.md#meter-exhausted)
- [The response headers](./api-rate-limits.md#limit-headers)
- [The failure mode that catches people](./endpoints-export.md#export-quota)

<a id="error-422"></a>

## 4. 422 — a value we will not guess at

An unknown provider or source_type, an unparseable timestamp, a malformed cursor, or a filter that can never match. The body names the value.

### 4.1 In depth

The design choice here is to fail loudly rather than return an empty page. An empty page from a typo is indistinguishable from an empty page from a genuinely narrow filter, and only one of those is your bug — so an unknown vocabulary value is an error with the value echoed back and the endpoint that enumerates the legal ones named in the message.

| `error` | Trigger | Cure |
| --- | --- | --- |
| `bad_provider` | A `provider` or `exclude_provider` value we do not publish. | Enumerate `/public/providers` at run time. |
| `bad_source_type` | An unknown `source_type`. | Enumerate `/v1/meta/facets` or `/public/facets`. |
| `field_never_published` | `require_fields=category` or `require_fields=seniority`. | Both are inferred by construction. Use `?category=` and read `category_confidence`. [Why](./ledger-provenance.md#the-422). |
| `bad_cursor` | A malformed or truncated `cursor`. | Pass `next_cursor` back verbatim and URL-encode it. |
| `bad_timestamp` | An unparseable `posted_after`, `verified_after` or `since`. | RFC3339, or a bare date where the parameter allows one. |
| `bad_offset` | `offset` beyond 100,000 on `/v1/companies`. | Use `cursor` for deep paging. |
| `limit_too_large` | `include_description=true` with `limit` above 50. | Lower the limit. This one is refused rather than clamped on purpose. |

### 4.2 Exact contract

Note which things are **clamps** rather than 422s, because the asymmetry is deliberate: `limit` above the plan maximum is silently reduced, a non-numeric `limit` falls back to the default, comma-separated lists are truncated at their per-parameter maximum, and unknown query parameters are ignored entirely. Those are all cases where the intent is unambiguous. A 422 is reserved for cases where guessing would produce a plausible-looking wrong answer.

Two real 422s

```console
$ curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs?provider=nosuch'
{"error":"bad_provider","message":"Unknown provider \"nosuch\". The list we publish
 is at /public/providers — keyless, so you can check it before you have an account
 — or as the `provider` facet on /v1/meta/facets.","docs":"…"}
$ curl -s -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs?require_fields=category'
{"error":"field_never_published","message":"category is read off the job title by
 our classifier, so it is always inferred. Filter it with ?category= and read
 category_confidence.","docs":"…"}
```

_Captured 2026-08-22. Both HTTP 422._

**See also**

- [Why require_fields=category is an error](./ledger-provenance.md#the-422)
- [Controlled vocabularies — never hard-code these](./api-parameters.md#filter-vocabularies)
- [Every provider, with its live row count](./ledger-sourcing.md#providers-list)

<a id="retry-policy"></a>

## 5. A retry policy that is correct

Retry 429 and 503, honouring Retry-After with jitter. Never retry 402, 401, 403, 422 or 500. Cap total attempts.

### 5.1 In depth

1. **Read the status first, not the body.** Branching on `error` is right for distinguishing two 402s; the decision to retry at all is a status decision.
2. **Honour `Retry-After` when it is present.** On a daily request breach it can be hours, and a fixed backoff will send thousands of requests that cannot succeed.
3. **Add jitter.** A fleet of workers that all back off for exactly 60 seconds will all return at exactly the same moment.
4. **Cap the attempts.** Three to five, then surface the failure. An unbounded retry loop against a 503 during a long outage is indistinguishable from an attack.
5. **Do not retry inside a stream.** A `/v1/export` that fails mid-body should resume with `after=<last id>`, not restart.

### 5.2 Exact contract

A small, correct client in Go

```go
package joa

import (
	"errors"
	"fmt"
	"math/rand"
	"net/http"
	"strconv"
	"time"
)

const base = "https://api.jobopportunitiesapi.org"

// Get sends one request, retrying only the statuses that can succeed later.
func Get(c *http.Client, key, path string) (*http.Response, error) {
	for attempt := 0; attempt < 5; attempt++ {
		req, err := http.NewRequest("GET", base+path, nil)
		if err != nil {
			return nil, err
		}
		req.Header.Set("Authorization", "Bearer "+key)
		res, err := c.Do(req)
		if err != nil {
			return nil, err
		}
		switch res.StatusCode {
		case http.StatusOK:
			return res, nil
		case http.StatusTooManyRequests, http.StatusServiceUnavailable:
			// The only two worth trying again. Retry-After is authoritative:
			// on a daily breach it is the seconds left until UTC midnight.
			wait := 5
			if v, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && v > 0 {
				wait = v
			}
			res.Body.Close()
			time.Sleep(time.Duration(wait)*time.Second +
				time.Duration(rand.Intn(1000))*time.Millisecond)
		default:
			// 402 especially: the licence is spent, not the patience.
			res.Body.Close()
			return nil, fmt.Errorf("joa: %s -> %s", path, res.Status)
		}
	}
	return nil, errors.New("joa: gave up after 5 attempts")
}
```

**See also**

- [Backing off correctly](./api-rate-limits.md#backoff)
- [Go](./recipes-languages.md#recipe-go)
- [402 — the licence, not the throttle](./api-errors.md#error-402)

---

## Where to go next

This file is part of **API**. Others in the same group:

- [API overview](./api-overview.md) — Hosts, versioning, caching, CORS and the HTTP conventions every endpoint follows. Read the hosts section first — getting it wrong costs people hours.
- [Authentication](./api-authentication.md) — One header, three accepted spellings, and a deliberate refusal to tell an attacker which kind of wrong a wrong key is.
- [Keyless access](./api-keyless.md) — What /public/* gives you with no account: real rows, one page at a time, bounded so that evaluating is free and extracting is not.
- [Rate limits](./api-rate-limits.md) — Two independent ceilings — requests per minute and per day — plus a monthly record allowance that is a licence rather than a throttle. They fail differently.
- [Pagination](./api-pagination.md) — Keyset cursors, not offsets. One rule matters more than the rest: hand next_cursor back exactly as you received it.

Always useful:

- [index.md](./index.md) — the map of every file here
- [BUILD-A-SITE.md](./BUILD-A-SITE.md) — the paste-whole brief for building against this API
- [quickstart.md](./quickstart.md) — zero to a first authenticated response
- [api-errors.md](./api-errors.md) — every status code and whether to retry it
