# 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.

**What this covers:** The per-plan limits; The response headers; Backing off correctly; Requests versus records.

**Assumed knowledge:** [Authentication](./api-authentication.md).

**Canonical HTML:** https://jobopportunitiesapi.org/docs/api/rate-limits  
**Machine-readable index:** https://jobopportunitiesapi.org/docs/ai/index.md  
**Last verified:** 2026-08-22  
**Superseded by:** the live API at https://api.jobopportunitiesapi.org and its spec at https://jobopportunitiesapi.org/openapi.json — where this file and the API disagree, the API is right.

---

> **429 and 402 are not the same event** — A **429** means you are sending requests too fast; wait `Retry-After` seconds and carry on. A **402** means this month's record allowance is spent; retrying will not help until the first of the month. A client that treats them alike will hammer a wall for three weeks.

---

<a id="limits-per-plan"></a>

## 1. The 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.

### 1.1 In depth

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.

### 1.2 Exact contract

| plan | display_name | price | records_per_month | req_per_day | req_per_min | delta_feed | bulk_export | single_country |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| explore | Explore | Free | 1,000 | 5,000 | 30 | false | false | false |
| growth | Growth | €80/month | 60,000 | 100,000 | 120 | true | false | false |
| signal | Signal | €299/month | 400,000 | 400,000 | 300 | true | true | true |
| scale | Scale | €899/month | 2,000,000 | 1,000,000 | 600 | true | true | false |

_Generated from `GET https://api.jobopportunitiesapi.org/public/plans` (no key required). Only purchasable plans appear there._

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](./api-keyless.md#keyless-limits).

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.

**See also**

- [The response headers](./api-rate-limits.md#limit-headers)
- [What counts as a record](./account-record-meter.md#meter-what-counts)
- [GET /v1/me](./endpoints-meta.md#endpoint-me)

<a id="limit-headers"></a>

## 2. The 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.

### 2.1 In depth

| 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.

### 2.2 Exact contract

Reading the headers

```console
$ 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
```

_Captured 2026-08-22 from a throwaway Signal key. Two rows requested, two records charged — the meter counts rows delivered, not requests made._

**See also**

- [Backing off correctly](./api-rate-limits.md#backoff)
- [What counts as a record](./account-record-meter.md#meter-what-counts)
- [Every status code](./api-errors.md#error-table)

<a id="backoff"></a>

## 3. Backing 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.

### 3.1 In depth

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](./endpoints-companies.md#slug-instability). |

### 3.2 Exact contract

A correct client, in standard-library Python — no dependencies

```python
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"))
```

**See also**

- [Every status code](./api-errors.md#error-table)
- [A retry policy that is correct](./api-errors.md#retry-policy)
- [Python](./recipes-languages.md#recipe-python)

<a id="limits-vs-meter"></a>

## 4. Requests versus records

The request limits protect the service. The record allowance is the licence. They are counted separately and they run out separately.

### 4.1 In depth

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.

### 4.2 Exact contract

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](./account-record-meter.md#meter-what-counts).

**See also**

- [What counts as a record](./account-record-meter.md#meter-what-counts)
- [Running out](./account-record-meter.md#meter-exhausted)
- [The country-locked plan](./account-record-meter.md#country-locked)

---

## Where to go next

This file is part of **API**. Others in the same group:

- [API overview](./api-overview.md) — Hosts, versioning, caching, CORS and the HTTP conventions every endpoint follows. Read the hosts section first — getting it wrong costs people hours.
- [Authentication](./api-authentication.md) — One header, three accepted spellings, and a deliberate refusal to tell an attacker which kind of wrong a wrong key is.
- [Keyless access](./api-keyless.md) — What /public/* gives you with no account: real rows, one page at a time, bounded so that evaluating is free and extracting is not.
- [Pagination](./api-pagination.md) — Keyset cursors, not offsets. One rule matters more than the rest: hand next_cursor back exactly as you received it.
- [Errors](./api-errors.md) — 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.

Always useful:

- [index.md](./index.md) — the map of every file here
- [BUILD-A-SITE.md](./BUILD-A-SITE.md) — the paste-whole brief for building against this API
- [quickstart.md](./quickstart.md) — zero to a first authenticated response
- [api-errors.md](./api-errors.md) — every status code and whether to retry it
