Job Opportunities API

Check the data. Then trust it.

account

The record meter

The licence is on rows delivered, not on requests made. This is the most commonly misunderstood mechanic here, and it changes how you write your client.

Last verified 2026-10-17 · Assumes: Rate limits. · Markdown copy

1What counts as a record

One row delivered to you, on any endpoint that returns rows. Metadata endpoints and /v1/me cost nothing.

In depthWhy it exists, what it is not, what people get wrong
CallRecords charged
GET /v1/jobs?limit=200up to 200: one per row returned
GET /v1/jobs/{id}1
GET /v1/jobs/closed?limit=50up to 50
GET /v1/jobs/expired?limit=10000: ids, closure dates and reasons only, no job rows; sends no X-RateLimit-Records-* headers (needs the delta feed: Growth and up)
GET /v1/changes?limit=500up to 500: one per change
GET /v1/companies?limit=25up to 25
GET /v1/companies/{slug}1
GET /v1/exportone per row written, charged as the stream is produced
GET /v1/me0
GET /v1/meta/facets, /meta/freshness, /meta/providers0
The STATISTICAL /public/* routes0: keyless and unmetered, but IP-rate-limited
/public/jobs, /public/jobs/{id}, /public/companies, /public/companies/{slug}0, not because they are keyless (they are not: they 401 without a key), but because they deliver no rows to a caller without one. Use /v1/jobs or /v1/companies.

An empty page costs nothing, because no rows were delivered. A page that returns fewer rows than you asked for charges for what arrived, not for what you asked.

Exact contractTypes, defaults, ranges, errors, edge cases

Metering is applied at a single gate that every row-returning endpoint passes through, rather than per handler. It used to be on /v1/jobs alone, which meant /v1/changes, /v1/jobs/closed, /v1/companies and /v1/jobs/{id} served rows and charged nothing: the allowance could be sidestepped by reading the same ledger through a different door. /v1/jobs/expired is the one deliberate exception: it returns ids, not rows, and is not charged.

The charge is fire-and-forget: the metering write happens after the response and never fails a request the customer has already received. Losing a charge is a rounding error; losing the response is not. In practice this means the counters can lag a moment behind reality under load.

Job rows and company rows are metered as separate scopes internally, which matters only for the country-locked SKU (see below). For every other key they share one monthly allowance.

2Records versus requests

Two independent counters. The request limits stop you overwhelming the service; the record allowance is what the plan actually sells.

In depthWhy it exists, what it is not, what people get wrong

Selling requests alone does not work for a dataset. Ten thousand requests a day at two hundred rows a page is two million rows a day (the whole ledger, daily), which is not a boundary at all. So the licence is on rows and the request ceilings exist for a different purpose.

Request limitsRecord allowance
PurposeProtect the origin from burstsThe licence: how much data you may take
Counted perKey, per minute and per dayKey, per calendar month
ResetsNext minute boundary; UTC midnightThe 1st of the month, 00:00 UTC (calendar month)
Breach status429402
Retry?Yes, after Retry-AfterNo, not until the 1st or an upgrade
Visible inX-RateLimit-Limit / -Remaining / -Reset, and /v1/me (usage_today)X-RateLimit-Records-* (incl. -Reset), /v1/me (records_used_month, records_limit_month, records_reset_at), and the dashboard

On most plans you will meet the record allowance long before the request ceiling. If you are meeting the request ceiling instead, you are almost certainly making many small requests where fewer larger ones would do.

Exact contractTypes, defaults, ranges, errors, edge cases

The practical design rules that follow: page at the largest limit you will actually consume; do not re-read rows you already have (use the delta feed); do not fetch descriptions for rows you will not display; and put staging on its own key so a runaway test job cannot spend production's month.

3Running out

A 402 with record_quota_exhausted. The allowance is the calendar month in UTC: it resets on the 1st at 00:00 UTC, the body gives the exact instant, and retrying before then will not help.

In depthWhy it exists, what it is not, what people get wrong

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 crossing a threshold rather than on the 402 arriving.

When it does arrive there are three responses: wait for the first of the month, upgrade the plan (which takes effect immediately), or reduce what you are asking for. The third is usually the right one if a daily full re-read is what emptied it.

On a free key (or a live trial) the 402's upgrade_url is a one-click link for that same key: opening it, with no sign-in, lands on a personal checkout that switches the key to Growth monthly. Same secret, nothing to change in your integration; any current launch offer is applied for you, so there is no code to enter. The link is signed, tied to the key and valid for 48 hours (7 days in the usage emails). Cancel anytime and the key simply returns to the free plan.

Exact contractTypes, defaults, ranges, errors, edge cases

The one place it does not arrive as a status is /v1/export. The stream has already returned 200, so the error is the last line of the body, carrying the after cursor to resume from. Export detail.

Watch the remaining allowance, from a cron job
remaining=$(curl -s -D - -o /dev/null -H "Authorization: Bearer $JOA_KEY" \
  'https://api.jobopportunitiesapi.org/v1/jobs?limit=1' \
  | awk -F': ' 'tolower($1)=="x-ratelimit-records-remaining"{print $2+0}')

# One record spent to learn where you stand: cheaper than discovering it
# by being cut off mid-sync.
[ "${remaining:-0}" -lt 5000 ] && echo "JOA records low: $remaining" >&2

4The country-locked plan

One country, no record limit on job rows. The restriction is what pays for the exemption, and the two are inseparable.

In depthWhy it exists, what it is not, what people get wrong

A country-locked key sees only its country. In exchange, job rows are exempt from the record meter entirely: X-RateLimit-Records-Limit reports unlimited and there is no monthly ceiling to run into. It suits a product that serves one market and needs volume within it.

The lock is not a filter you can lift. A request for another country returns nothing, and /v1/changes is scoped the same way. Every response carries X-JOA-Country-Lock with the ISO code, on purpose: the only thing worse than the restriction is a customer discovering it by wondering where France went.

Exact contractTypes, defaults, ranges, errors, edge cases
BehaviourOn a country-locked key
Job rowsOnly the locked country. Unmetered.
Company rowsMetered normally. The exemption is for job rows only.
X-JOA-Country-LockPresent on every response, carrying the ISO code.
X-RateLimit-Records-Limitunlimited on job-row responses.
Request limitsUnchanged: the per-minute and per-day ceilings still apply.
?country= for another codeReturns nothing. Not an error.
/v1/changesScoped to the locked country.

Availability is a plan property (single_country on /public/plans) rather than a separate product. If your use is one market and the record ceiling is what is stopping you, this is the shape to ask about at /contact.

This page was rendered 27 October 2026, 11:29 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.