Job Opportunities API

Check the data. Then trust it.

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.

Last verified 2026-08-22 · Assumes: Authentication. · Markdown copy

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
Every error body, without exception
{
  "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.

FieldTypeAlways presentMeaning
docsstringno
errorstringno
messagestringno

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
CodeWhat it means
200A page of listings.
401Missing, unknown, revoked or expired key. All four are reported identically.
402The 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.
422A 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.
429Over your plan's per-minute or per-day limit. Retry-After says how long.
Status`error`Retry?Cause and cure
400variesNoA malformed request — usually a parameter that cannot be parsed at all.
401unauthorizedNoMissing, unknown, revoked or expired key. All four are identical by design. Detail.
402record_quota_exhaustedNoThe month's record allowance is spent. Detail.
402key_required_to_pageNoA cursor on a keyless request. Take a free key. Detail.
403upgrade_requiredNoThe endpoint is not in your plan. X-JOA-Required-Feature names what is missing.
301NoA company slug that has been retired. Location carries the current slug — follow it (curl -L). Never cached.
404not_foundNo*No such row. *Except a company slug, which can move — re-resolve rather than delete.
422severalNoA value the API will not guess at. The body names it. Detail.
429rate_limitedYesOver the per-minute or per-day request limit. Wait Retry-After.
500query_failedNoSomething is broken. Retrying will not help. Please report it.
503timeoutYesThe query ran past its time limit. Retry in ~5s and narrow the filter.
503unavailableYesA 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.

The 402 body
{
  "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`TriggerCure
bad_providerA provider or exclude_provider value we do not publish.Enumerate /public/providers at run time.
bad_source_typeAn unknown source_type.Enumerate /v1/meta/facets or /public/facets.
field_never_publishedrequire_fields=category or require_fields=seniority.Both are inferred by construction. Use ?category= and read category_confidence. Why.
bad_cursorA malformed or truncated cursor.Pass next_cursor back verbatim and URL-encode it.
bad_timestampAn unparseable posted_after, verified_after or since.RFC3339, or a bare date where the parameter allows one.
bad_offsetoffset beyond 100,000 on /v1/companies.Use cursor for deep paging.
limit_too_largeinclude_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.

Two real 422s
$ 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.

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
  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.
Exact contractTypes, defaults, ranges, errors, edge cases
A small, correct client in 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")
}

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.