# Troubleshooting

> Something looks wrong. What should I check first?

Source: https://docs.betterjobs.cc/resources/troubleshooting/

Find your symptom below. Each one lists what to check, in order. Every response carries an `X-Request-Id` header. Keep it: it is what support needs.

## I got no results

`data` is `[]`. This costs 0 credits. Check, in order:

1. **Which sources were tried.** Read `metadata.providers.tried`. On Free and Starter, or with `strategy: own_only`, only `betterjobs` (the BetterJobs index) is searched. Partner providers start on Growth. See [Credits and billing](https://docs.betterjobs.cc/concepts/credits-and-billing.md).
2. **The time window.** `posted_within_days` counts from when a job was first seen. A window of `1` or `2` days is narrow. Try `30`.
3. **Title keywords.** `title_or` matches title keywords. Add common variants: `["Head of RevOps", "Head of Revenue Operations", "RevOps Lead"]`. Check that `title_not` is not excluding them.
4. **Country codes.** `country_code_or` takes ISO 3166-1 alpha-2 codes: `GB`, not `UK`.
5. **Filters that drop unknowns.** `salary_min_gte` excludes every job whose `salary` is `null`. Many postings do not state pay, so this filter removes a lot. `remote: true` keeps remote jobs only.
6. **The strategy.** `consensus` returns only jobs seen by at least `min_sources` sources (default 2). Switch to `cheapest_first` or `max_coverage` to see single-source jobs.
7. **Closed jobs.** Closed jobs are excluded unless you set `include_closed: true`.
8. **The status.** If `metadata.status` is `partial`, a provider failed. See [below](#i-got-a-partial-result).

Before running a wide search, send it with `"dry_run": true`. The free estimate returns `expected_unique_jobs_range` and `providers_planned`. A range of `0` to `0` means the filters are too tight.

> Sandbox data is fixed
>
> `POST /v1/sandbox/jobs/search` returns the same illustrative jobs whatever you send. It cannot tell you whether your filters are right. Use a live key for that.

## I got a partial result

`metadata.status` is `partial`, or an async search ended with `status: partial`. This is not an error.

- One or more providers failed or missed `waterfall.timeout_ms`. They are listed in `metadata.providers.failed` with `provider_timeout` or `provider_error`.
- You got everything the other sources found, and you paid only for that.
- Check `GET /v1/providers` for the source’s `status`.

To recover: retry later (jobs you already paid for are free), raise `timeout_ms` up to 30,000, or leave the failing source out of `waterfall.providers`. See [Provider status](https://docs.betterjobs.cc/resources/provider-status.md).

## 400 unknown\_filter or invalid\_request

- `unknown_filter`: a field in `filters` does not exist. BetterJobs rejects unknown fields instead of ignoring them, so a typo never silently widens your search. `error.param` names the field, and the message suggests a fix. A common cause is pasting another provider’s filter names, for example `job_title_or` instead of `title_or`. See [Filters](https://docs.betterjobs.cc/platform/filters.md).
- `invalid_request`: a value is out of range or malformed, for example `limit` above 100 on `POST /v1/jobs/search`. Read `error.param` and `error.message`.

## 401 unauthorized

Send the header exactly as `Authorization: Bearer bj_live_...`. Check for a missing `Bearer `, a trailing space or newline from copy-paste, or a revoked key. Sandbox keys start with `bj_test_`. See [Authentication](https://docs.betterjobs.cc/getting-started/authentication.md).

## 402 insufficient\_credits

You do not have enough credits for this request.

- The body has `error.credits_needed` and `error.upgrade_url`.
- `GET /v1/account` shows `credits_remaining` and `credits_reset_at`.
- Lower `waterfall.max_credits` or `limit` so the request fits what you have left.
- An async search does not fail when credits run out. It moves to `status: on_hold` and resumes after a top-up.

See [Errors](https://docs.betterjobs.cc/platform/errors.md#insufficient_credits).

## 403 plan\_required

You asked for a provider or feature your plan does not include, often by listing a provider in `waterfall.providers`. `error.required_plan` names the plan you need. Remove the provider, or check `enabled_on_your_plan` in `GET /v1/providers` first.

## 429 rate\_limited

You sent too many requests in the current window.

- Wait the number of seconds in the `Retry-After` header, then retry.
- Watch `RateLimit-Remaining` and `RateLimit-Reset` on every response and slow down before you hit `0`.
- No-code tools often send one request per row at full speed. Throttle the step (Clay request rate, n8n batching, Make scheduling).

**curl**

```bash
# -i prints the response headers, including RateLimit-* and Retry-After
curl -i https://api.betterjobs.cc/v1/account \
  -H "Authorization: Bearer $BETTERJOBS_API_KEY" \
  -H "BetterJobs-Version: 2026-10-01"
```

**Python**

```python
import os
import time
import requests


def post_with_retry(url: str, body: dict) -> dict:
    headers = {
        "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}",
        "BetterJobs-Version": "2026-10-01",
    }
    while True:
        resp = requests.post(url, headers=headers, json=body, timeout=30)
        if resp.status_code != 429:
            resp.raise_for_status()
            return resp.json()
        time.sleep(int(resp.headers["Retry-After"]))
```

**TypeScript**

```ts
async function postWithRetry(url: string, body: unknown): Promise<unknown> {
  while (true) {
    const res = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`,
        "BetterJobs-Version": "2026-10-01",
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });
    if (res.status !== 429) {
      if (!res.ok) throw new Error(`BetterJobs ${res.status}: ${await res.text()}`);
      return res.json();
    }
    await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After")) * 1000));
  }
}
```

See [Rate limits](https://docs.betterjobs.cc/platform/rate-limits.md).

## Webhook signature does not match

Your computed HMAC differs from `v1` in `BetterJobs-Signature`. The cause is almost always the input, not the algorithm. Check:

1. **Raw body.** Sign the exact bytes you received. If your framework or tool parsed the JSON and you re-serialize it, key order and whitespace change and the HMAC breaks. Use the raw body option: n8n *Raw Body*, Make *JSON pass-through*, Zapier *Catch Raw Hook*, Express `express.raw()`.
2. **Signed string.** It is `<t>.<raw body>`: the `t` value from the header, a dot, then the body. Not the body alone.
3. **Header parsing.** Split the header on `,`, then each part on the first `=`. `t` is unix seconds. `v1` is lowercase hex.
4. **Secret.** Use the secret of the endpoint that received the event. A different endpoint has a different secret.
5. **Encoding.** Compare hex to hex. Do not base64 the digest.
6. **Clock.** If you reject old timestamps, check your server clock. A 5-minute tolerance is common.

Once your check is fixed, replay missed events with `POST /v1/webhooks/replay`. Replays are free and never charge again. See [Webhooks](https://docs.betterjobs.cc/platform/webhooks.md).

## I think I was charged twice

BetterJobs charges a job once. Re-reading a job you already paid for is free, whichever endpoint returns it. To check a specific job, read the ledger:

**curl**

```bash
curl "https://api.betterjobs.cc/v1/billing/ledger?job_id=job_01JC8X4M2Q7RV3T9KD5W6YH0AB" \
  -H "Authorization: Bearer $BETTERJOBS_API_KEY" \
  -H "BetterJobs-Version: 2026-10-01"
```

**Python**

```python
import os
import requests


resp = requests.get(
    "https://api.betterjobs.cc/v1/billing/ledger",
    params={"job_id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB"},
    headers={
        "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}",
        "BetterJobs-Version": "2026-10-01",
    },
    timeout=30,
)
resp.raise_for_status()
for e in resp.json()["data"]:
    print(e["created_at"], e["operation"], e["reason"], e["credits"])
```

**TypeScript**

```ts
const url = new URL("https://api.betterjobs.cc/v1/billing/ledger");
url.searchParams.set("job_id", "job_01JC8X4M2Q7RV3T9KD5W6YH0AB");
const res = await fetch(url, {
  headers: {
    Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`,
    "BetterJobs-Version": "2026-10-01",
  },
});
if (!res.ok) throw new Error(`BetterJobs ${res.status}: ${await res.text()}`);
const { data } = await res.json();
for (const e of data) console.log(e.created_at, e.operation, e.reason, e.credits);
```

Illustrative result: one charge, then two free re-reads.

```json
{
  "data": [
    { "id": "led_0Zc5XvB9nM", "job_id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "request_id": "req_1Mn4BvC7xZ", "operation": "GET /v1/jobs/{id}", "credits": 0, "reason": "already_paid", "created_at": "2026-10-11T12:40:00Z" },
    { "id": "led_8Yb4WuA3mL", "job_id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "request_id": "req_6Lk3AzX2wY", "operation": "POST /v1/jobs/search", "credits": 0, "reason": "already_paid", "created_at": "2026-10-11T11:05:00Z" },
    { "id": "led_2Xa3VtZ1kK", "job_id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "request_id": "req_7Hc2LmQ9xT", "operation": "POST /v1/jobs/search", "credits": 1, "reason": "charged", "created_at": "2026-10-11T09:12:00Z" }
  ],
  "next_cursor": null
}
```

Then check the usual causes of a real second charge:

- **Two different jobs.** Two postings that look alike (same title, different city or team) are two canonical jobs with two `id` values. Compare the ids.
- **Company profiles.** `GET /v1/companies/{domain}` costs 1 credit per request, every time. There is no already-paid rule for profiles.
- **Retried POSTs.** Send an `Idempotency-Key` on every `POST`. A retry with the same key and body returns the first response and never charges twice. See [Idempotency](https://docs.betterjobs.cc/platform/idempotency.md).

Each response’s `X-Credits-Charged` header and `metadata.credits_charged` show what that request cost. If the ledger still shows two `charged` entries for one `job_id`, contact support with both `request_id` values.

## Related

- [Errors](https://docs.betterjobs.cc/platform/errors.md) for every error code.
- [Credits and billing](https://docs.betterjobs.cc/concepts/credits-and-billing.md) for what is and is not charged.
- [Glossary](https://docs.betterjobs.cc/resources/glossary.md) for terms used here.
