api
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.
1The shape of an error
Always JSON, always three fields: a stable error code to branch on, a human message, and a docs link.
In depthWhy it exists, what it is not, what people get wrong
{
"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.
Exact contractTypes, defaults, ranges, errors, edge cases
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 | Meaning |
|---|---|---|---|
docs | string | no | |
error | string | no | |
message | string | no |
2Every 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.
In depthWhy it exists, what it is not, what people get wrong
| Code | What it means |
|---|---|
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. |
| 402 | record_quota_exhausted | No | The month's record allowance is spent. Detail. |
| 402 | key_required_to_page | No | A cursor on a keyless request. Take a free key. Detail. |
| 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. |
| 422 | several | No | A value the API will not guess at. The body names it. Detail. |
| 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. |
Exact contractTypes, defaults, ranges, errors, edge cases
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.
3402 — 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.
In depthWhy it exists, what it is not, what people get wrong
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.
{
"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"
}Exact contractTypes, defaults, ranges, errors, edge cases
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.
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.
4422 — 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.
In depthWhy it exists, what it is not, what people get wrong
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. |
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. |
Exact contractTypes, defaults, ranges, errors, edge cases
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.
$ 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":"…"}
5A 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.
In depthWhy it exists, what it is not, what people get wrong
- Read the status first, not the body. Branching on
erroris right for distinguishing two 402s; the decision to retry at all is a status decision. - 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.
- Add jitter. A fleet of workers that all back off for exactly 60 seconds will all return at exactly the same moment.
- 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.
- Do not retry inside a stream. A
/v1/exportthat fails mid-body should resume withafter=<last id>, not restart.
Exact contractTypes, defaults, ranges, errors, edge cases
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")
}This page was rendered 11 September 2026, 05:32 UTC. Every figure on it comes from the endpoint named beside it, and every published request is re-sent against the live API before this site is allowed to build. If something here is wrong, the thumbs-down above reaches a person.