> ## Documentation Index
> Fetch the complete documentation index at: https://jobo.world/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors

> Every status code the API returns, the three response envelopes, the stable error codes you can branch on, and what to retry.

## Check the status code, then the envelope

The API returns **three different error shapes** depending on which layer rejected the request. Write your error handling against the status code first, and only then read the body.

| Layer                                   | Content type               | Shape                              | Statuses                            |
| --------------------------------------- | -------------------------- | ---------------------------------- | ----------------------------------- |
| **Gateway** — auth, rate limits, wallet | `application/json`         | `{ "error", "detail", … }`         | `401` `402` `403` `429`             |
| **Endpoint** — validation, conflicts    | `application/problem+json` | RFC 7807 `ProblemDetails` + `code` | `400` `404` `409` `413` `415` `422` |
| **Unhandled** — unexpected failure      | `application/json`         | `{ "message" }`                    | `500`                               |

<Warning>
  A `200` is not always a success. [`GET /api/locations/geocode`](/docs/api-reference/locations/geocode-a-location) returns `200` with `succeeded: false` for input it cannot resolve, and a search filter with an unrecognised value returns `200` with `total: 0` rather than an error. See [silent failures](#silent-failures).
</Warning>

***

## Status codes

| Code  | Meaning                | Retry?         | What to do                                                                     |
| ----- | ---------------------- | -------------- | ------------------------------------------------------------------------------ |
| `200` | OK                     | —              | Check `succeeded` on geocode; check `total` on search.                         |
| `202` | Accepted               | —              | Auto Apply create and cancel. Poll for the outcome.                            |
| `400` | Bad Request            | ❌ Fix request  | Read `detail`. Includes undecodable cursors.                                   |
| `401` | Unauthorized           | ❌ Fix key      | Missing, malformed, revoked, or expired `X-Api-Key`.                           |
| `402` | Payment Required       | ❌ Top up       | Wallet balance too low, **or** the endpoint needs a subscription.              |
| `403` | Forbidden              | ❌              | Managed feed without a customer key, or a sandbox-token audience mismatch.     |
| `404` | Not Found              | ❌              | Unknown id. Note a company with no jobs returns `200` and an empty array.      |
| `409` | Conflict               | ⚠️ Conditional | Feed cursor must be restarted, or an idempotency-key conflict.                 |
| `413` | Payload Too Large      | ❌ Fix request  | Shrink the request body.                                                       |
| `415` | Unsupported Media Type | ❌ Fix request  | Send `Content-Type: application/json`.                                         |
| `422` | Unprocessable Content  | ❌ Fix request  | Well-formed JSON the endpoint cannot act on.                                   |
| `429` | Too Many Requests      | ✅              | Wait `Retry-After` seconds. See [Rate limits](/docs/rate-limits).                   |
| `499` | Client Closed Request  | —              | You disconnected before the response was written.                              |
| `500` | Internal Server Error  | ✅              | Exponential backoff. If persistent, check [status](https://jobo.world/status). |
| `503` | Service Unavailable    | ⚠️ Conditional | Feed backend busy — honour `Retry-After`. Auto Apply create is gated off.      |

<Note>
  **Credits are never charged for a failed request.** Per-delivered-unit endpoints (search, feed, company jobs) run a balance precheck and only debit after a successful response is built. Flat-rate endpoints such as geocode debit up front and are **automatically refunded on any `4xx` or `5xx`**. See [Billing](/docs/billing).
</Note>

***

## Gateway errors

These come from middleware, before your request reaches an endpoint. They are plain JSON, **not** `problem+json`.

### 401 Unauthorized

Also sets `WWW-Authenticate: ApiKey realm="api.jobo.world"`.

```json theme={null}
{ "error": "Missing X-Api-Key header" }
```

```json theme={null}
{ "error": "Invalid or expired API key" }
```

### 402 Payment Required

Two distinct causes share this status. Tell them apart by the `error` string.

Wallet balance too low — also sets `X-Credits-Required` and `X-Credits-Balance`:

```json theme={null}
{
  "error": "Insufficient credits",
  "detail": "This request requires 75 credits (0.075 USD); wallet balance is 12. Top up to continue.",
  "group": "JobSearch",
  "credits_required": 75,
  "credits_balance": 12,
  "quota_remaining": 0,
  "cost_per_request_usd": 0.075
}
```

The endpoint requires a subscription:

```json theme={null}
{
  "error": "Active subscription required",
  "detail": "This endpoint requires an active JobsFeed subscription.",
  "required_package": "JobsFeed",
  "current_package": null
}
```

### 403 Forbidden

Only two situations produce a `403`:

* `POST /api/jobs/feed/managed` called with anything other than a customer API key — master, marketplace, and sandbox keys are rejected.
* A sandbox token used against a product it was not minted for.

```json theme={null}
{
  "api_version": "2026-07-21",
  "error": "sandbox_token_audience_forbidden",
  "detail": "This sandbox token is not valid for the requested product endpoint."
}
```

<Note>
  There is no `403` for "your plan does not include this endpoint", and API keys have **no scopes or per-endpoint permissions**. Plan-gated access returns `402`.
</Note>

### 429 Too Many Requests

Also sets `Retry-After` and the `X-RateLimit-*` headers. `group` names the [rate-limit group](/docs/rate-limits) you exhausted — `Default`, `JobSearch`, `JobFeed`, `Geocode`, `AutoApply`, or `AutoApplyManagement`.

```json theme={null}
{
  "code": "rate_limit_exceeded",
  "error": "Rate limit exceeded",
  "detail": "Per-minute request limit reached.",
  "group": "JobSearch",
  "retry_after_seconds": 42
}
```

***

## Endpoint errors

Validation and conflict errors follow [RFC 7807](https://datatracker.ietf.org/doc/html/rfc7807) with `Content-Type: application/problem+json`.

```json theme={null}
{
  "title": "Invalid cursor",
  "detail": "The supplied cursor could not be decoded.",
  "status": 400,
  "code": "invalid_cursor",
  "docs_url": "https://jobo.world/docs/guides/sync-a-database"
}
```

### Stable error codes

`code` is a stable, lowercase, machine-readable string. Branch on it rather than on `title` or `detail`, which are prose and may be reworded at any time.

| `code`                         | Status | Meaning                                                          |
| ------------------------------ | ------ | ---------------------------------------------------------------- |
| `invalid_request`              | `400`  | A parameter failed validation.                                   |
| `invalid_cursor`               | `400`  | The cursor could not be decoded. Restart pagination.             |
| `feed_cursor_restart_required` | `409`  | A legacy non-stable cursor reached the deep-pagination boundary. |
| `request_conflict`             | `409`  | Conflicts with current state or a prior idempotent request.      |
| `payload_too_large`            | `413`  | Request body exceeded the limit.                                 |
| `unsupported_media_type`       | `415`  | Send `Content-Type: application/json`.                           |
| `unprocessable_request`        | `422`  | Valid JSON, but not actionable.                                  |
| `rate_limit_exceeded`          | `429`  | A rate-limit window is exhausted.                                |

<Warning>
  `code` is present on **`400`, `409`, `413`, `415`, `422`, and `429` only**, and only for requests authenticated with a customer API key. `401`, `402`, `403`, and `404` carry **no** `code` — match on the status and the `error` string for those. Auto Apply is the exception: it returns `code` on its domain errors throughout.
</Warning>

Responses carrying a `code` also carry `docs_url`, pointing at the relevant page here.

***

## Unhandled errors

An unexpected server-side failure returns a minimal envelope. A `detail` field is only populated outside production, so never depend on it.

```json theme={null}
{ "message": "An error occurred" }
```

***

## Auto Apply errors

[Auto Apply](/docs/api-reference/auto-apply/auto-apply) is a contract preview and is not accepting traffic. `POST /api/auto-apply/applications` is deployment-gated and returns:

```json theme={null}
{
  "title": "auto_apply_coming_soon",
  "status": 503,
  "detail": "Auto Apply application creation is coming soon.",
  "code": "auto_apply_coming_soon",
  "api_version": "2026-07-21"
}
```

This signals availability, not transient capacity — **do not retry it**. Every Auto Apply response, errors included, carries `api_version`.

At launch, domain errors will use `problem+json` with a stable `code`. Planned conflict codes include `webhook_secret_not_configured`, `idempotency_key_reuse`, `idempotency_key_in_flight`, `ambiguous_ats`, and `not_cancelable`; `422` adds `job_apply_url_missing` and `unsupported_ats`.

***

## Silent failures

Three cases return a successful status with no data and no error. They account for most "the API returned nothing" reports.

<AccordionGroup>
  <Accordion title="An unrecognised filter value returns zero results, not a 400" icon="filter-circle-xmark">
    Enum filters are matched **literally** against the index. There is no validation and no `400` — an unknown value matches nothing, and you get `200` with `total: 0`.

    ```bash theme={null}
    # Zero results — "senior level" is not a canonical value
    curl "https://connect.jobo.world/api/jobs?experience_level=senior+level" \
      -H "X-Api-Key: $JOBO_API_KEY"

    # Matches
    curl "https://connect.jobo.world/api/jobs?experience_level=senior" \
      -H "X-Api-Key: $JOBO_API_KEY"
    ```

    Send the canonical values listed under [Enums](/docs/api-reference/jobs/object#enums). Jobs where the field is null are excluded from any filter on that field.

    On `POST /api/jobs/search`, array values are **not** comma-split: `["full-time,contract"]` is one literal value and matches nothing. Use `["full-time", "contract"]`.
  </Accordion>

  <Accordion title="An unknown query parameter is ignored, not rejected" icon="question">
    Unrecognised query parameters are silently dropped, so a typo returns **unfiltered** results that look plausible. `?is_remote=true` is not a parameter — the filter is `work_model=remote` — so that request succeeds while filtering nothing.

    The same applies to `include_facets`: unknown names are dropped, so `?include_facets=work_models` (plural typo) yields **no** facets rather than the default set.
  </Accordion>

  <Accordion title="Geocode returns 200 for input it cannot resolve" icon="location-dot">
    Placeholder and unresolvable input is not an error. Check the `succeeded` field, not the status code.

    ```json theme={null}
    {
      "input": "2 Locations",
      "succeeded": false,
      "error": "Location string is a placeholder, not a real location."
    }
    ```

    Only a missing or whitespace-only `location` parameter is a real `400`. See [Resolve locations](/docs/guides/resolve-locations#unresolvable-input).
  </Accordion>
</AccordionGroup>

***

## Retry strategy

Retry **only** `429`, `500`, and `503`. Everything else is a request you must change.

<Steps>
  <Step title="Honour Retry-After">
    On `429` and `503`, wait exactly the number of seconds in the `Retry-After` header. Never hardcode a delay.
  </Step>

  <Step title="Back off exponentially with jitter">
    For `500`, start at 1s and double, capping around 30s. Add ±25% jitter so concurrent clients do not retry in lockstep.
  </Step>

  <Step title="Cap attempts">
    Stop after 3–5 tries. Persistent `429` means your sustained rate is too high — slow the pipeline down rather than retrying harder.
  </Step>

  <Step title="Treat a feed 409 as a restart, not a retry">
    On `feed_cursor_restart_required`, discard the cursor and begin pagination again with `{"stable_scan": true, "batch_size": 1000}`. Retrying the same cursor fails forever.
  </Step>

  <Step title="Send an idempotency key on writes">
    So a retry cannot double-charge or duplicate work. See [Idempotency](/docs/idempotency).
  </Step>
</Steps>

```python theme={null}
import random, time, requests

RETRYABLE = {429, 500, 503}

def request_with_retry(method, url, *, headers, max_attempts=5, **kwargs):
    response = None
    for attempt in range(max_attempts):
        response = requests.request(method, url, headers=headers, **kwargs)
        if response.status_code not in RETRYABLE:
            return response

        # Retry-After is authoritative when present (429, 503).
        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else min(2**attempt, 30)
        time.sleep(delay * random.uniform(0.75, 1.25))

    return response
```

The official Python client retries these statuses for you and raises typed exceptions — `JoboAuthenticationError`, `JoboRateLimitError`, `JoboValidationError`, and `JoboServerError`, all deriving from `JoboError`. See [Client libraries](/docs/sdks).

***

## Getting support

Every response carries an **`x-correlation-id`** header. Quote it when contacting [support@jobo.world](mailto:support@jobo.world) — it identifies the exact request in our logs.

```bash theme={null}
curl -si "https://connect.jobo.world/api/jobs?q=engineer&page_size=1" \
  -H "X-Api-Key: $JOBO_API_KEY" | grep -i x-correlation-id
```
