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. |
400 | A parameter the endpoint does not take. unknown_parameter: the name
is not a parameter of this endpoint; the message names it and, when a
valid parameter is within a small edit distance, suggests it
("unknown parameter: 'countyr'; did you mean 'country'?").
unsupported_parameter: a real parameter of another endpoint that has
no meaning here (for example limit on /v1/export). Refused before
the meter: nothing is charged. |
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 allowance resets. The monthly window is the CALENDAR month in UTC: it resets on the 1st of the next month at 00:00 UTC, and the body gives the exact instant. Do not retry before then. Every metered response carries X-RateLimit-Records-Remaining and X-RateLimit-Records-Reset (the reset instant, Unix seconds), so this is visible long before it happens. |
422 | A parameter value we will not guess at: an unknown source_type or provider, an unparseable timestamp, a malformed cursor, a limit outside 1-200 or not a whole number (bad_limit), a flag that is not true/false (bad_boolean), a comma-separated list longer than its maximum (too_many_values), a text
filter the query grammar cannot honour (query_syntax: an unmatched
parenthesis, an empty group, an OR with no term on one side, a bare
AND or NOT, since 2026-10-25). Nothing is charged. The one visible reduction: a limit within 1-200 but above the plan's page cap is reduced to the cap and flagged in X-JOA-Limit-Applied. Unknown parameter NAMES are a 400 (BadRequest), never ignored. |
429 | Over your plan's per-minute or per-day limit. Retry-After says how long (seconds to the next minute boundary, or to UTC midnight on a daily breach). The body says exactly when the limit resets (resets_at, resets_at_text) and, when a higher plan exists, links a same-key upgrade (upgrade_url). |
| Status | `error` | Retry? | Cause and cure |
|---|---|---|---|
| 400 | unknown_parameter, unsupported_parameter | No | A parameter the endpoint does not take. The message names it and, when one is close, suggests the parameter you meant ('countyr'; did you mean 'country'?). |
| 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 | Retired. Used to appear when a cursor was sent to a keyless /public/jobs or /public/companies request. Those two routes now return 401 unconditionally, cursor or not. Detail. |
| 403 | upgrade_required | No | The endpoint is not in your plan. X-JOA-Required-Feature names what is missing. |
| 301 | not available | 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": "You've used this month's 1,000 free records on the Explore plan. Your allowance resets on 1 November 2026 at 00:00 UTC. To keep going now, switch this key to Growth (60,000 records a month, EUR 80/month): one click, same key, nothing to change in your integration: https://jobopportunitiesapi.org/upgrade?k=<signed token> Cancel anytime; your key simply returns to the free plan.",
"docs": "https://jobopportunitiesapi.org/api",
"limit": 1000,
"used": 1000,
"plan": "explore",
"window": "calendar_month_utc",
"resets_at": "2026-11-01T00:00:00Z",
"resets_at_text": "1 November 2026 at 00:00 UTC",
"upgrade_plan": "growth",
"upgrade_url": "https://jobopportunitiesapi.org/upgrade?k=<signed token>",
"docs_url": "https://jobopportunitiesapi.org/docs/account/record-meter#meter-exhausted"
}| Field | Meaning |
|---|---|
error, message, docs | The same envelope as every other error. message is written for a person and can be shown as it is. |
limit, used | The limit that was hit and what the window had used. Records for a 402; requests per day or per minute for a 429. |
plan | The key's plan. |
window | calendar_month_utc (402), day_utc or minute (429). The monthly window is the calendar month in UTC, not a rolling 30 days. |
resets_at, resets_at_text | The exact reset instant (RFC 3339, UTC) and the same instant in plain English. A 402 resets on the 1st of next month at 00:00 UTC; a daily 429 at the next 00:00 UTC; a per-minute 429 at the next minute. |
upgrade_plan, upgrade_url | For a free (Explore) key or a live trial, upgrade_url is a one-click link for the same key: opening it, with no sign-in, lands on a personal Creem checkout that switches THIS key to Growth monthly (same secret, nothing to change in your integration; any current launch offer is applied for you). The link is signed, tied to the key and valid for 48 hours; a paid subscriber, a reseller or a marketplace key never gets one. For other keys it is the next plan up and a same-key checkout link (the signed-in owner of the key completes it). Omitted on the top plan and for marketplace keys, which get the reset time only. |
docs_url | This documentation, at the section on the limit that was hit. |
A 429 carries the same fields (window is day_utc or minute; Retry-After and X-RateLimit-Reset are unchanged). A 402 also sets X-RateLimit-Records-Reset, the reset instant in Unix seconds. All of the new fields are additive: error, message and docs keep their meaning, and code that reads only those keeps working. The offer field that a 402 used to carry is gone.
Exact contractTypes, defaults, ranges, errors, edge cases
There used to be a second, unrelated 402 with the code key_required_to_page: a cursor sent to a keyless /public/jobs or /public/companies request got refused while an uncursored first page still answered keylessly. That two-tier arrangement is retired: both routes now return 401 unconditionally, cursor or not, to anyone who is not jobopportunitiesapi.org's own page rendering. Branch on error, not on the status, if you still carry code written for the old behaviour.
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_listed_after · bad_posted_after · bad_verified_after | 422: an unparseable listed_after, posted_after or verified_after. | RFC3339, or a bare date. |
bad_since | 400: an unparseable since timestamp. 422: a since cursor we did not issue. | RFC3339 on the first call; after that, the next_since we returned, verbatim. |
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. |
bad_limit | limit not a whole number from 1 to 200. | Send 1 to 200. Above your plan's page cap the page is reduced to the cap and flagged in X-JOA-Limit-Applied, not refused. |
bad_boolean | A flag such as include_description or include_total that is not true or false. | Send true or false. |
too_many_values | A comma-separated list longer than its maximum (country 60, city 12, ...). | Split the request into several. |
filter_too_long | q, title, title_exclude or description_contains of 200 bytes or more. | Shorten it. |
query_has_no_terms | A text filter made only of operators and punctuation. | Include at least one word or number. |
query_syntax | A text filter the query grammar cannot honour: an unmatched parenthesis, an empty group (), an OR with no term on one side, a bare AND or NOT. Since 25 Oct 2026. | The message names the character and the spelling that works. The grammar. |
sort_unavailable | Any sort value: results are always ordered newest first on this deployment. | Remove sort. |
Exact contractTypes, defaults, ranges, errors, edge cases
There is one visible reduction and no silent ones. A limit inside 1 to 200 but above your plan's page cap is reduced to the cap, and the response says so in X-JOA-Limit-Applied. Everything else that cannot be honoured exactly is refused before the meter, so nothing is charged: an unknown parameter (400 unknown_parameter), a parameter an endpoint does not support (400 unsupported_parameter, e.g. limit on /v1/export), an out-of-range limit, a malformed flag or an over-long list.
$ 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 27 October 2026, 11:27 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.