# Rate limits

> How many requests can I send, and what do I do on a 429?

Source: https://docs.betterjobs.cc/platform/rate-limits/

Each account can send a fixed number of requests per time window. Responses tell you where you stand, so you can slow down before you hit the limit. If you do hit it, you get `429 rate_limited` and a `Retry-After` header that says how long to wait.

## Find your limit

`GET /v1/account` returns your limit as `rate_limit`. It is free.

```json
{
  "plan": "growth",
  "credits_remaining": 9841,
  "credits_reset_at": "2026-11-01T00:00:00Z",
  "providers_enabled": ["betterjobs", "theirstack", "techmap"],
  "rate_limit": { "limit": 60, "window_seconds": 60 }
}
```

Illustrative. `limit` is requests per window and `window_seconds` is the window length. Read your own values from the endpoint rather than hard-coding them.

## Headers

| Header                | On         | What it tells you                       |
| --------------------- | ---------- | --------------------------------------- |
| `RateLimit-Limit`     | Responses  | Requests allowed in the current window. |
| `RateLimit-Remaining` | Responses  | Requests left in the current window.    |
| `RateLimit-Reset`     | Responses  | Seconds until the window resets.        |
| `Retry-After`         | `429` only | Seconds to wait before you retry.       |

The names follow the IETF draft `RateLimit` header fields, without an `X-` prefix. `RateLimit-Reset` is a number of seconds from now, not a Unix timestamp.

A `429` body uses the standard [error shape](https://docs.betterjobs.cc/platform/errors.md#the-error-body):

```json
{
  "error": {
    "type": "rate_limit_error",
    "code": "rate_limited",
    "message": "Rate limit exceeded. Retry after 12 seconds.",
    "doc_url": "https://docs.betterjobs.cc/platform/errors/#rate_limited",
    "request_id": "req_0Ig1JqH5sT"
  }
}
```

A `429` returns no jobs, so it charges nothing.

## Retry with backoff

Wrap every call in one helper:

- On `429`, wait exactly `Retry-After` seconds.
- On `5xx` or a network error, wait with exponential backoff plus jitter, capped at 30 seconds.
- Stop after a few attempts and raise.
- For `POST`, pass the same `Idempotency-Key` on every attempt, so a retry can never double up. See [Idempotency](https://docs.betterjobs.cc/platform/idempotency.md).

**curl**

```bash
# curl waits for Retry-After on 429 and backs off on 5xx.
curl https://api.betterjobs.cc/v1/jobs/search \
  --retry 5 --retry-max-time 120 \
  -H "Authorization: Bearer $BETTERJOBS_API_KEY" \
  -H "BetterJobs-Version: 2026-10-01" \
  -H "Idempotency-Key: 7b8f2c4e-1a3d-4f5b-9c6e-0d2a4b6c8e1f" \
  -H "Content-Type: application/json" \
  -d '{ "filters": { "title_or": ["Head of RevOps"], "country_code_or": ["DE", "AT", "CH"] } }'
```

**Python**

```python
import random
import time


import requests


MAX_ATTEMPTS = 5




def send(method: str, url: str, **kwargs) -> requests.Response:
    """Send a request, retrying 429, 5xx and network errors. Reuses kwargs (and Idempotency-Key) on every attempt."""
    for attempt in range(MAX_ATTEMPTS):
        last = attempt == MAX_ATTEMPTS - 1
        try:
            resp = requests.request(method, url, timeout=60, **kwargs)
        except (requests.ConnectionError, requests.Timeout):
            if last:
                raise
            time.sleep(min(2**attempt, 30) + random.random())
            continue
        if resp.status_code == 429 and not last:
            time.sleep(int(resp.headers["Retry-After"]))
        elif resp.status_code >= 500 and not last:
            time.sleep(min(2**attempt, 30) + random.random())
        else:
            resp.raise_for_status()
            return resp
    raise AssertionError("unreachable")
```

**TypeScript**

```ts
const MAX_ATTEMPTS = 5;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const backoffMs = (attempt: number) => (Math.min(2 ** attempt, 30) + Math.random()) * 1000;


/** Send a request, retrying 429, 5xx and network errors. Reuses init (and Idempotency-Key) on every attempt. */
export async function send(url: string, init: RequestInit): Promise<Response> {
  for (let attempt = 0; ; attempt++) {
    const last = attempt === MAX_ATTEMPTS - 1;
    let res: Response;
    try {
      res = await fetch(url, init);
    } catch (err) {
      if (last) throw err;
      await sleep(backoffMs(attempt));
      continue;
    }
    if (res.status === 429 && !last) {
      await sleep(Number(res.headers.get("Retry-After")) * 1000);
    } else if (res.status >= 500 && !last) {
      await sleep(backoffMs(attempt));
    } else {
      if (!res.ok) throw new Error(`BetterJobs ${res.status}: ${await res.text()}`);
      return res;
    }
  }
}
```

> Do not retry other 4xx codes
>
> `400`, `401`, `402`, `403`, `404` and `409` fail the same way every time until you change something. Retrying them only burns your rate limit. The [errors page](https://docs.betterjobs.cc/platform/errors.md#should-i-retry) says what to do for each.

## Stay under the limit

Backoff handles the occasional `429`. For steady workloads, avoid them:

- **Pace on `RateLimit-Remaining`.** When it reaches `0`, wait `RateLimit-Reset` seconds before the next call.
- **Ask for bigger pages.** `limit: 100` on `POST /v1/jobs/search` needs a quarter of the requests that the default `25` does.
- **Use async searches for bulk work.** One `POST /v1/searches` collects up to 10,000 jobs. See [Async searches](https://docs.betterjobs.cc/platform/async-searches.md).
- **Prefer webhooks to polling.** A watch or `webhook_url` pushes events to you. Polling `GET /v1/searches/{id}` or `GET /v1/events` in a tight loop spends requests on “nothing yet”.
- **Share one limiter.** Parallel workers on one account share one budget. Put one rate limiter in front of all of them.

## Related

- [Errors](https://docs.betterjobs.cc/platform/errors.md)
- [Idempotency](https://docs.betterjobs.cc/platform/idempotency.md)
- [Get your account API reference](/api/operations/getaccount/)
