# Errors

> What does each error code mean, and should I retry?

Source: https://docs.betterjobs.cc/platform/errors/

BetterJobs uses HTTP status codes for request-level failures and one JSON error shape for all of them. Provider failures are different: they never fail your request. You get `200` with partial results and pay only for what was returned.

## The error body

Every error response has the same shape:

```json
{
  "error": {
    "type": "billing_error",
    "code": "insufficient_credits",
    "message": "This request needs at least 25 credits; 3 remain.",
    "credits_needed": 25,
    "upgrade_url": "https://betterjobs.cc/pricing",
    "doc_url": "https://docs.betterjobs.cc/platform/errors/#insufficient_credits",
    "request_id": "req_6Ek5FuM9wX"
  }
}
```

| Field            | Always present                             | What it is                                                                                                                                                                 |
| ---------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`           | Yes                                        | Broad class: `invalid_request_error`, `authentication_error`, `billing_error`, `permission_error`, `not_found_error`, `conflict_error`, `rate_limit_error` or `api_error`. |
| `code`           | Yes                                        | The specific error. Branch on this.                                                                                                                                        |
| `message`        | Yes                                        | Human-readable explanation. It can change. Never parse it.                                                                                                                 |
| `doc_url`        | Yes                                        | Link to this code’s row below.                                                                                                                                             |
| `request_id`     | Yes                                        | Same as the `X-Request-Id` header. Quote it to support.                                                                                                                    |
| `param`          | On `invalid_request`, `unknown_filter`     | The offending field, for example `filters.job_title_or`.                                                                                                                   |
| `credits_needed` | On `insufficient_credits`                  | Credits the request needs.                                                                                                                                                 |
| `required_plan`  | On `plan_required`                         | The plan that includes the feature or provider.                                                                                                                            |
| `upgrade_url`    | On `insufficient_credits`, `plan_required` | Where to top up or upgrade.                                                                                                                                                |

> Branch on code, not on message
>
> `error.code` is stable within an [API version](https://docs.betterjobs.cc/platform/versioning.md). `error.message` is for humans and may be reworded at any time.

## Error codes

Each row has an anchor. `doc_url` in an error body links straight to it.

| Code                                            | HTTP  | Cause                                                                            | Fix                                                                                                                                               | Retry             | Extra fields                    |
| ----------------------------------------------- | ----- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | ------------------------------- |
| [`invalid_request`](#invalid_request)           | 400   | The body or a parameter is malformed or out of range.                            | Read `error.param` and `error.message`, then fix the request.                                                                                     | after fix         | `param`                         |
| [`unknown_filter`](#unknown_filter)             | 400   | A field in `filters` does not exist. Unknown fields are rejected, never ignored. | Check the name against the filter list. `error.param` names the bad field.                                                                        | after fix         | `param`                         |
| [`unauthorized`](#unauthorized)                 | 401   | Missing, malformed or revoked API key.                                           | Send `Authorization: Bearer bj_live_...` (or `bj_test_...`).                                                                                      | after fix         | —                               |
| [`insufficient_credits`](#insufficient_credits) | 402   | Not enough credits left for this request.                                        | Top up or upgrade at `error.upgrade_url`, or lower `waterfall.max_credits`.                                                                       | after top-up      | `credits_needed`, `upgrade_url` |
| [`plan_required`](#plan_required)               | 403   | Your plan does not include this feature or provider.                             | Upgrade to `error.required_plan`, or remove the provider from `waterfall.providers`.                                                              | no                | `required_plan`, `upgrade_url`  |
| [`not_found`](#not_found)                       | 404   | The id or domain does not exist.                                                 | Check the id prefix (`job_`, `srch_`, `wat_`, `evt_`) and value.                                                                                  | no                | —                               |
| [`idempotency_conflict`](#idempotency_conflict) | 409   | The same `Idempotency-Key` was reused with a different body.                     | Use a new key for a new request.                                                                                                                  | after fix         | —                               |
| [`rate_limited`](#rate_limited)                 | 429   | Too many requests in the current window.                                         | Wait `Retry-After` seconds. Watch `RateLimit-Remaining`.                                                                                          | after Retry-After | —                               |
| [`provider_timeout`](#provider_timeout)         | 200\* | A provider missed `waterfall.timeout_ms`.                                        | Not an HTTP error. You get `200` with `metadata.status: partial` and the provider in `metadata.providers.failed`. You pay only for returned jobs. | yes               | —                               |
| [`provider_error`](#provider_error)             | 200\* | A provider returned an error.                                                    | Not an HTTP error. You get `200` with `metadata.status: partial` and the provider in `metadata.providers.failed`. You pay only for returned jobs. | yes               | —                               |
| [`internal_error`](#internal_error)             | 500   | Something failed on our side.                                                    | Retry with the same `Idempotency-Key`. You will not be charged twice.                                                                             | yes               | —                               |

\* Not an HTTP error. Reported inside a 200 response as `metadata.status: partial`.

## Partial results

A slow or failing provider never turns your request into an error. BetterJobs drops it, returns what the other sources found, and says so in `metadata`:

```json
{
  "data": ["..."],
  "next_cursor": null,
  "metadata": {
    "request_id": "req_9Qa4NvB2sE",
    "status": "partial",
    "credits_charged": 1,
    "providers": {
      "tried": ["betterjobs", "techmap", "theirstack", "coresignal"],
      "hit": ["betterjobs", "techmap", "theirstack"],
      "failed": [{ "provider": "coresignal", "code": "provider_timeout" }],
      "contributions": { "betterjobs": 1, "techmap": 0, "theirstack": 0 }
    }
  }
}
```

Illustrative and shortened from the spec example.

- `metadata.status` is `complete` or `partial`. The HTTP status is `200` either way.
- `metadata.providers.failed` lists each dropped provider with a code: `provider_timeout` (it missed `waterfall.timeout_ms`) or `provider_error` (it returned an error).
- You are billed only for jobs in the response. A failed provider costs nothing.

What to do with a partial result depends on the job:

- **Good enough.** Use it. `metadata.providers.hit` shows which sources did answer.
- **Need full coverage.** Retry the same request later. Jobs you already paid for come back free, so a retry costs only the new jobs the missing provider adds.
- **Timeouts again and again.** Raise `waterfall.timeout_ms` (up to `30000`), or check the provider’s live status with `GET /v1/providers`. See [Provider status](https://docs.betterjobs.cc/resources/provider-status.md).

Async searches report the same thing as `status: partial` on the search. See [Async searches](https://docs.betterjobs.cc/platform/async-searches.md#status).

## Should I retry?

| Situation                             | Retry?       | How                                                                                                            |
| ------------------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------- |
| `429 rate_limited`                    | Yes          | Wait `Retry-After` seconds, then retry. See [Rate limits](https://docs.betterjobs.cc/platform/rate-limits.md). |
| `500 internal_error`                  | Yes          | Back off, then retry with the **same** `Idempotency-Key`. You are not charged twice.                           |
| Network error or timeout, no response | Yes          | Retry with the same `Idempotency-Key`. Without one, you may create a second async search or watch.             |
| `metadata.status: partial`            | Optional     | Retry later if you need the missing provider. Already-paid jobs are free.                                      |
| `402 insufficient_credits`            | After top-up | Top up at `error.upgrade_url`, or lower `waterfall.max_credits`.                                               |
| `400`, `401`, `409`                   | After a fix  | The same request fails the same way. Fix it first.                                                             |
| `403 plan_required`, `404 not_found`  | No           | Change the request or the plan. Retrying does not help.                                                        |

`GET` and `DELETE` requests are safe to retry as they are. For `POST`, send an `Idempotency-Key` so a retry can never double up. See [Idempotency](https://docs.betterjobs.cc/platform/idempotency.md).

The [backoff helper on the rate limits page](https://docs.betterjobs.cc/platform/rate-limits.md#retry-with-backoff) implements this table for `429` and `5xx`.

## Handle errors in code

**curl**

```bash
# -i prints the status line and headers, including X-Request-Id
curl -i https://api.betterjobs.cc/v1/jobs/search \
  -H "Authorization: Bearer $BETTERJOBS_API_KEY" \
  -H "BetterJobs-Version: 2026-10-01" \
  -H "Content-Type: application/json" \
  -d '{ "filters": { "job_title_or": ["Head of RevOps"] } }'
# HTTP/2 400 ... "code": "unknown_filter", "param": "filters.job_title_or"
```

**Python**

```python
import os
import requests




class BetterJobsError(Exception):
    def __init__(self, status: int, error: dict):
        super().__init__(f"{status} {error['code']}: {error['message']} (request {error['request_id']})")
        self.status = status
        self.code = error["code"]
        self.error = error




def call(method: str, path: str, **kwargs) -> dict:
    resp = requests.request(
        method,
        f"https://api.betterjobs.cc/v1{path}",
        headers={
            "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}",
            "BetterJobs-Version": "2026-10-01",
        },
        timeout=60,
        **kwargs,
    )
    if resp.status_code >= 400:
        raise BetterJobsError(resp.status_code, resp.json()["error"])
    return resp.json()




try:
    result = call("POST", "/jobs/search", json={"filters": {"title_or": ["Head of RevOps"]}})
except BetterJobsError as e:
    if e.code == "insufficient_credits":
        print("Top up:", e.error["upgrade_url"], "needed:", e.error["credits_needed"])
    raise
else:
    if result["metadata"]["status"] == "partial":
        print("Missing providers:", result["metadata"]["providers"]["failed"])
```

**TypeScript**

```ts
export class BetterJobsError extends Error {
  constructor(
    readonly status: number,
    readonly error: { code: string; message: string; request_id: string; [k: string]: unknown },
  ) {
    super(`${status} ${error.code}: ${error.message} (request ${error.request_id})`);
  }
}


export async function call(method: string, path: string, body?: unknown) {
  const res = await fetch(`https://api.betterjobs.cc/v1${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`,
      "BetterJobs-Version": "2026-10-01",
      "Content-Type": "application/json",
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  if (res.status >= 400) throw new BetterJobsError(res.status, (await res.json()).error);
  return res.json();
}


try {
  const result = await call("POST", "/jobs/search", { filters: { title_or: ["Head of RevOps"] } });
  if (result.metadata.status === "partial") console.warn("Missing providers:", result.metadata.providers.failed);
} catch (e) {
  if (e instanceof BetterJobsError && e.error.code === "insufficient_credits") {
    console.error("Top up:", e.error.upgrade_url, "needed:", e.error.credits_needed);
  }
  throw e;
}
```

> Errors are free
>
> A request that returns an error returns no jobs, so it charges no credits. `X-Credits-Charged` appears only on billable responses.

## Related

- [Troubleshooting](https://docs.betterjobs.cc/resources/troubleshooting.md)
- [Filters](https://docs.betterjobs.cc/platform/filters.md#unknown-filters-are-rejected)
- [Credits and billing](https://docs.betterjobs.cc/concepts/credits-and-billing.md)
