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
Section titled “I got no results”data is []. This costs 0 credits. Check, in order:
- Which sources were tried. Read
metadata.providers.tried. On Free and Starter, or withstrategy: own_only, onlybetterjobs(the BetterJobs index) is searched. Partner providers start on Growth. See Credits and billing. - The time window.
posted_within_dayscounts from when a job was first seen. A window of1or2days is narrow. Try30. - Title keywords.
title_ormatches title keywords. Add common variants:["Head of RevOps", "Head of Revenue Operations", "RevOps Lead"]. Check thattitle_notis not excluding them. - Country codes.
country_code_ortakes ISO 3166-1 alpha-2 codes:GB, notUK. - Filters that drop unknowns.
salary_min_gteexcludes every job whosesalaryisnull. Many postings do not state pay, so this filter removes a lot.remote: truekeeps remote jobs only. - The strategy.
consensusreturns only jobs seen by at leastmin_sourcessources (default 2). Switch tocheapest_firstormax_coverageto see single-source jobs. - Closed jobs. Closed jobs are excluded unless you set
include_closed: true. - The status. If
metadata.statusispartial, a provider failed. See below.
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.
I got a partial result
Section titled “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 inmetadata.providers.failedwithprovider_timeoutorprovider_error. - You got everything the other sources found, and you paid only for that.
- Check
GET /v1/providersfor the source’sstatus.
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.
400 unknown_filter or invalid_request
Section titled “400 unknown_filter or invalid_request”unknown_filter: a field infiltersdoes not exist. BetterJobs rejects unknown fields instead of ignoring them, so a typo never silently widens your search.error.paramnames the field, and the message suggests a fix. A common cause is pasting another provider’s filter names, for examplejob_title_orinstead oftitle_or. See Filters.invalid_request: a value is out of range or malformed, for examplelimitabove 100 onPOST /v1/jobs/search. Readerror.paramanderror.message.
401 unauthorized
Section titled “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.
402 insufficient_credits
Section titled “402 insufficient_credits”You do not have enough credits for this request.
- The body has
error.credits_neededanderror.upgrade_url. GET /v1/accountshowscredits_remainingandcredits_reset_at.- Lower
waterfall.max_creditsorlimitso the request fits what you have left. - An async search does not fail when credits run out. It moves to
status: on_holdand resumes after a top-up.
See Errors.
403 plan_required
Section titled “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
Section titled “429 rate_limited”You sent too many requests in the current window.
- Wait the number of seconds in the
Retry-Afterheader, then retry. - Watch
RateLimit-RemainingandRateLimit-Reseton every response and slow down before you hit0. - No-code tools often send one request per row at full speed. Throttle the step (Clay request rate, n8n batching, Make scheduling).
# -i prints the response headers, including RateLimit-* and Retry-Aftercurl -i https://api.betterjobs.cc/v1/account \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01"import osimport timeimport 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"]))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.
Webhook signature does not match
Section titled “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:
- 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(). - Signed string. It is
<t>.<raw body>: thetvalue from the header, a dot, then the body. Not the body alone. - Header parsing. Split the header on
,, then each part on the first=.tis unix seconds.v1is lowercase hex. - Secret. Use the secret of the endpoint that received the event. A different endpoint has a different secret.
- Encoding. Compare hex to hex. Do not base64 the digest.
- 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.
I think I was charged twice
Section titled “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 "https://api.betterjobs.cc/v1/billing/ledger?job_id=job_01JC8X4M2Q7RV3T9KD5W6YH0AB" \ -H "Authorization: Bearer $BETTERJOBS_API_KEY" \ -H "BetterJobs-Version: 2026-10-01"import osimport 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"])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.
{ "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
idvalues. 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-Keyon everyPOST. A retry with the same key and body returns the first response and never charges twice. See Idempotency.
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
Section titled “Related”- Errors for every error code.
- Credits and billing for what is and is not charged.
- Glossary for terms used here.