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
Section titled “The error body”Every error response has the same shape:
{ "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. |
Error codes
Section titled “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 | 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 | 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 | 401 | Missing, malformed or revoked API key. | Send Authorization: Bearer bj_live_... (or bj_test_...). | after fix | — |
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 | 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 | 404 | The id or domain does not exist. | Check the id prefix (job_, srch_, wat_, evt_) and value. | no | — |
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 | 429 | Too many requests in the current window. | Wait Retry-After seconds. Watch RateLimit-Remaining. | after Retry-After | — |
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 | 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 | 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
Section titled “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:
{ "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.statusiscompleteorpartial. The HTTP status is200either way.metadata.providers.failedlists each dropped provider with a code:provider_timeout(it missedwaterfall.timeout_ms) orprovider_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.hitshows 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 to30000), or check the provider’s live status withGET /v1/providers. See Provider status.
Async searches report the same thing as status: partial on the search. See Async searches.
Should I retry?
Section titled “Should I retry?”| Situation | Retry? | How |
|---|---|---|
429 rate_limited |
Yes | Wait Retry-After seconds, then retry. See Rate limits. |
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.
The backoff helper on the rate limits page implements this table for 429 and 5xx.
Handle errors in code
Section titled “Handle errors in code”# -i prints the status line and headers, including X-Request-Idcurl -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"import osimport 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"]) raiseelse: if result["metadata"]["status"] == "partial": print("Missing providers:", result["metadata"]["providers"]["failed"])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;}