api
Rate limits
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.
1The per-plan limits
Every plan has a requests-per-minute and a requests-per-day ceiling. Both are on the key, and this table comes from the live price list.
In depthWhy it exists, what it is not, what people get wrong
The two request ceilings protect the origin from bursts. They are not the product boundary — the record allowance is — which is why they are set generously relative to the records you are allowed to consume. On most plans you will meet the record allowance long before you meet the request limit.
The per-minute limit is a rolling minute; the per-day limit resets at UTC midnight. They are enforced together on the same request, so a burst can trip the first without touching the second.
Exact contractTypes, defaults, ranges, errors, edge cases
| Plan | Price | Records / month | Requests / day | Requests / min | Delta feed | Bulk export |
|---|---|---|---|---|---|---|
Exploreexplore | Free | 1,000 | 5,000 | 30 | no | no |
Growthgrowth | €80/month | 60,000 | 100,000 | 120 | yes | no |
Signalsignal | €299/month | 400,000 | 400,000 | 300 | yes | yes |
Scalescale | €899/month | 2,000,000 | 1,000,000 | 600 | yes | yes |
Rendered from /public/plans when this page was built. Only purchasable plans appear there, so this table is the price list — if a plan is not here you cannot buy it. Compare on /pricing.
Keyless /public/* traffic is limited per IP instead, at about two requests a second sustained and forty a minute, with a ten-minute block on breach. The keyless limits in detail.
The /v1/me endpoint reports the limits attached to your key and today's usage, and it does not consume records — so it is the right thing to poll if you want to show a user where they stand.
2The response headers
Every keyed response carries where you stand on both the request limit and the record meter, so nobody discovers an allowance by being cut off.
In depthWhy it exists, what it is not, what people get wrong
| Header | On | Meaning |
|---|---|---|
X-RateLimit-Limit | every keyed response | Your plan's requests per day. |
X-RateLimit-Remaining | every keyed response | Requests left today. Floors at 0. |
X-RateLimit-Reset | every keyed response | Unix seconds at the next UTC midnight, when the daily counter resets. |
X-RateLimit-Records-Limit | row-returning responses | Records included in the plan this month. unlimited on a country-locked key. |
X-RateLimit-Records-Used | row-returning responses | Records consumed this month, including the rows in this response. |
X-RateLimit-Records-Remaining | row-returning responses | What is left. Floors at 0. |
Retry-After | 429 and 503 | Seconds to wait. On a per-minute breach it is 60; on a daily breach it is the seconds remaining until UTC midnight. |
X-JOA-Required-Feature | 403 | The entitlement your plan is missing: delta_feed or bulk_export. |
X-JOA-Country-Lock | every response on a country-locked key | The ISO code the key is restricted to. Present so nobody discovers the restriction by wondering where the other countries went. |
X-RateLimit-Records-Used counts the current response, which is the useful definition: after a request that returned 200 rows, the header tells you what you have spent including those 200.
Exact contractTypes, defaults, ranges, errors, edge cases
$ curl -s -D - -o /dev/null -H "Authorization: Bearer $JOA_KEY" \ 'https://api.jobopportunitiesapi.org/v1/jobs?country=FR&limit=2' | grep -i '^x-ratelimit' x-ratelimit-limit: 400000 x-ratelimit-records-limit: 400000 x-ratelimit-records-remaining: 399998 x-ratelimit-records-used: 2 x-ratelimit-remaining: 399998 x-ratelimit-reset: 1787443200
3Backing off correctly
Respect Retry-After. Retry 429 and 503; do not retry 402, 401, 403 or 422. Jitter your retries so a fleet does not synchronise.
In depthWhy it exists, what it is not, what people get wrong
The API tells you how long to wait rather than leaving you to guess, and the number is meaningful: on a per-minute breach it is 60 seconds, and on a daily breach it is however many seconds remain until UTC midnight — which can be hours. A client with a fixed one-second backoff will send thousands of pointless requests into a daily breach.
| Status | Retry? | How |
|---|---|---|
| 429 | Yes | Wait Retry-After seconds. Add jitter. |
503 with Retry-After | Yes | A query ran past its time limit. Retry in ~5s, and consider narrowing the filter — a country, or fewer require_fields, makes it reliably fast. |
| 500 | No | The request is broken. Retrying will not help. |
| 402 | No | The month's records are spent. Stop until the 1st, or upgrade. |
| 401 / 403 | No | A credential or entitlement problem. Fix it. |
| 422 | No | A value the API will not guess at. The body names it. |
| 404 | No | …except for a company slug, which may have moved. See why. |
Exact contractTypes, defaults, ranges, errors, edge cases
import json, os, random, time, urllib.error, urllib.request
API = "https://api.jobopportunitiesapi.org"
KEY = os.environ["JOA_KEY"]
# Statuses worth trying again. Everything else is a decision, not a hiccup:
# 402 means the licence is spent, 401/403 mean the credential is wrong,
# 422 means a value we sent is not one the API will guess at.
RETRYABLE = {429, 503}
def get(path, attempts=5):
req = urllib.request.Request(
API + path,
headers={"Authorization": f"Bearer {KEY}", "Accept": "application/json"},
)
for attempt in range(attempts):
try:
with urllib.request.urlopen(req, timeout=60) as r:
return json.load(r)
except urllib.error.HTTPError as e:
if e.code not in RETRYABLE or attempt == attempts - 1:
raise
# The API says how long to wait, and on a daily breach that can be
# hours. Guessing here is how a client spends a night sending
# requests that cannot succeed.
wait = int(e.headers.get("Retry-After") or 2 ** attempt)
time.sleep(wait + random.uniform(0, 1)) # jitter: unsynchronise a fleet
print(get("/v1/me"))4Requests versus records
The request limits protect the service. The record allowance is the licence. They are counted separately and they run out separately.
In depthWhy it exists, what it is not, what people get wrong
This is the distinction that makes the pricing make sense, and it is worth getting straight before you compare this API with a request-priced one. A request limit asks “how hard are you hitting the service”. A record allowance asks “how much of the data have you taken”.
Selling requests alone does not work here. Ten thousand requests a day at 200 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 only to stop a client from overwhelming the origin.
The practical consequence: limit=200 costs the same in requests as limit=1 and two hundred times as much in records. Ask for what you will use.
Exact contractTypes, defaults, ranges, errors, edge cases
Metering is applied at the gate every row-returning endpoint passes through, not per handler. It used to be on /v1/jobs alone, which meant /v1/changes, /v1/jobs/closed, /v1/jobs/expired, /v1/companies and /v1/jobs/{id} all served rows and charged nothing — the allowance could be sidestepped by reading the same ledger through a different door.
The charge is fire-and-forget: a metering write never fails a request the customer has already received. Losing a charge is a rounding error; losing the response is not. The full definition of what counts as a record is on the record meter page.
This page was rendered 11 September 2026, 19:03 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.