# Quickstart

> How do I make my first BetterJobs request in under a minute?

Source: https://docs.betterjobs.cc/getting-started/quickstart/

Four steps: call the keyless sandbox, read the response, repeat the call with your own key, then build your own query. The first step needs no signup.

## 1. Call the sandbox (no key)

The sandbox has the same request and response shape as `POST /v1/jobs/search`. It needs no key, charges nothing and calls no provider. It returns fixed illustrative data.

**curl**

```bash
curl https://api.betterjobs.cc/v1/sandbox/jobs/search \
  -H "Content-Type: application/json" \
  -d '{
    "filters": {
      "title_or": ["Head of RevOps"],
      "country_code_or": ["DE", "AT", "CH"],
      "posted_within_days": 7
    },
    "limit": 10
  }'
```

**Python**

```python
import requests


resp = requests.post(
    "https://api.betterjobs.cc/v1/sandbox/jobs/search",
    json={
        "filters": {
            "title_or": ["Head of RevOps"],
            "country_code_or": ["DE", "AT", "CH"],
            "posted_within_days": 7,
        },
        "limit": 10,
    },
)
resp.raise_for_status()
body = resp.json()
for job in body["data"]:
    print(job["title"], "@", job["company"]["name"], [s["provider"] for s in job["sources"]])
```

**TypeScript**

```ts
const resp = await fetch('https://api.betterjobs.cc/v1/sandbox/jobs/search', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    filters: {
      title_or: ['Head of RevOps'],
      country_code_or: ['DE', 'AT', 'CH'],
      posted_within_days: 7,
    },
    limit: 10,
  }),
});
if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`);
const body = await resp.json();
for (const job of body.data) {
  console.log(job.title, '@', job.company.name, job.sources.map((s: { provider: string }) => s.provider));
}
```

## 2. Read the response

This is the sandbox response. Data is illustrative.

```json
{
  "data": [
    {
      "id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB",
      "title": "Head of Revenue Operations",
      "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" },
      "location": { "city": "Berlin", "region": "Berlin", "country_code": "DE", "remote": false },
      "employment_type": "full_time",
      "seniority": "lead",
      "job_family": "operations",
      "salary": { "min": 110000, "max": 135000, "currency": "EUR", "period": "year", "origin": "declared" },
      "description": "Acme Robotics is hiring a Head of Revenue Operations to own forecasting, CRM hygiene and the GTM tool stack across DACH.",
      "apply_url": "https://jobs.acme-robotics.example/revops-lead/apply",
      "posted_at": "2026-10-08T00:00:00Z",
      "first_seen_at": "2026-10-08T06:40:00Z",
      "last_seen_at": "2026-10-11T06:10:00Z",
      "last_verified_at": "2026-10-11T06:10:00Z",
      "status": "open",
      "closed_reason": null,
      "repost_count": 0,
      "p_real": 0.94,
      "sources": [
        {
          "provider": "betterjobs",
          "provider_job_id": "bj_idx_5521907",
          "url": "https://jobs.acme-robotics.example/revops-lead",
          "first_seen_at": "2026-10-08T06:40:00Z",
          "last_seen_at": "2026-10-11T06:10:00Z",
          "fields": ["title", "description", "apply_url", "location", "employment_type", "posted_at"]
        },
        {
          "provider": "theirstack",
          "provider_job_id": "ts_88213377",
          "url": "https://jobs.acme-robotics.example/revops-lead",
          "first_seen_at": "2026-10-08T07:12:00Z",
          "last_seen_at": "2026-10-11T04:02:00Z",
          "fields": ["salary", "seniority"]
        },
        {
          "provider": "techmap",
          "provider_job_id": "tm_3f9a2c71",
          "url": "https://boards.example/acme-robotics/revops-lead",
          "first_seen_at": "2026-10-08T09:30:00Z",
          "last_seen_at": "2026-10-10T23:40:00Z",
          "fields": ["job_family"]
        }
      ],
      "license": { "display": true, "resale": false }
    }
  ],
  "next_cursor": null,
  "metadata": {
    "request_id": "req_sandbox_0001",
    "status": "complete",
    "credits_charged": 0,
    "jobs_already_paid": 0,
    "duplicates_merged": 2,
    "credits_remaining": 0,
    "providers": {
      "tried": ["betterjobs", "techmap", "theirstack"],
      "hit": ["betterjobs", "techmap", "theirstack"],
      "failed": [],
      "contributions": { "betterjobs": 1, "techmap": 0, "theirstack": 0 }
    },
    "field_coverage": { "salary": 1.0, "seniority": 1.0, "location.remote": 1.0, "description": 1.0 },
    "latency_ms": 12
  }
}
```

The response has three blocks.

### `data`: canonical jobs

Each item is one real opening, merged from every source that saw it. Three providers saw this job. You get it once.

- `id` is stable across sources and requests. Store it. Re-reading a job you already paid for is free.
- `sources[]` lists each provider that saw the job, its own id and URL, when it saw it, and which `fields` it contributed. Here the BetterJobs index gave the title and description, TheirStack gave `salary` and `seniority`, and Techmap gave `job_family`.
- `p_real` is the probability (0 to 1) that this is a real open req, from cross-source corroboration. See [Provenance and confidence](https://docs.betterjobs.cc/concepts/provenance-and-confidence.md).
- `salary.origin` says whether pay was `declared` in the posting or `inferred` by a source.
- `license` says whether you may show the job to end users (`display`) and resell it as data (`resale`). See [Licensing](https://docs.betterjobs.cc/concepts/licensing.md).

> null is not false
>
> `null` means unknown. `[]` means verified none. A missing field is not part of that payload, for example the lifecycle-only job in a `job.closed` event. Never read `null` as “no”.

### `next_cursor`: paging

`null` means this is the last page. Otherwise pass it back as `cursor` with the same filters to get the next page. See [Pagination](https://docs.betterjobs.cc/platform/pagination.md).

### `metadata`: what happened

| Field                                | Meaning                                                                                                         |
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `request_id`                         | Unique id for this request. Also in the `X-Request-Id` header. Quote it to support.                             |
| `status`                             | `complete`, or `partial` when at least one provider failed or timed out. You are billed only for returned jobs. |
| `credits_charged`                    | Credits this request cost. `0` in the sandbox.                                                                  |
| `jobs_already_paid`                  | Returned jobs you had paid for before. Free.                                                                    |
| `duplicates_merged`                  | Provider records merged into a canonical job. Free. Here, 2.                                                    |
| `credits_remaining`                  | Credits left on your account. `0` in the sandbox, which has no account.                                         |
| `providers.tried` / `hit` / `failed` | Which sources were asked, which matched, and which failed (`provider_timeout` or `provider_error`).             |
| `providers.contributions`            | Unique jobs each source contributed first.                                                                      |
| `field_coverage`                     | Share (0 to 1) of returned jobs with a non-null value, per field. This is measured live on every response.      |
| `latency_ms`                         | Time the request took.                                                                                          |

## 3. Use your own key

1. Sign up at [betterjobs.cc](https://betterjobs.cc). The Free plan has 1,000 credits on the BetterJobs index. No card.

2. Create an API key in your dashboard. Live keys start with `bj_live_`. Store it in an environment variable, never in client-side code. See [Authentication](https://docs.betterjobs.cc/getting-started/authentication.md).

   ```bash
   export BETTERJOBS_API_KEY="bj_live_..."
   ```

3. Ask for a free estimate first. `dry_run: true` returns the expected job count, the providers it would call and a credit range. Nothing is fetched or charged.

   **curl**

   ```bash
   curl 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": {
         "title_or": ["Head of RevOps"],
         "country_code_or": ["DE", "AT", "CH"],
         "posted_within_days": 7
       },
       "limit": 25,
       "dry_run": true
     }'
   ```

   **Python**

   ```python
   import os
   import requests


   resp = requests.post(
       "https://api.betterjobs.cc/v1/jobs/search",
       headers={
           "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}",
           "BetterJobs-Version": "2026-10-01",
       },
       json={
           "filters": {
               "title_or": ["Head of RevOps"],
               "country_code_or": ["DE", "AT", "CH"],
               "posted_within_days": 7,
           },
           "limit": 25,
           "dry_run": True,
       },
   )
   resp.raise_for_status()
   print(resp.json()["estimate"])
   ```

   **TypeScript**

   ```ts
   const resp = await fetch('https://api.betterjobs.cc/v1/jobs/search', {
     method: 'POST',
     headers: {
       Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`,
       'BetterJobs-Version': '2026-10-01',
       'Content-Type': 'application/json',
     },
     body: JSON.stringify({
       filters: {
         title_or: ['Head of RevOps'],
         country_code_or: ['DE', 'AT', 'CH'],
         posted_within_days: 7,
       },
       limit: 25,
       dry_run: true,
     }),
   });
   if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`);
   console.log((await resp.json()).estimate);
   ```

4. Run the real search. Drop `dry_run` and add `waterfall.max_credits` as a hard cap. Results stop at the cap.

   **curl**

   ```bash
   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": {
         "title_or": ["Head of RevOps"],
         "country_code_or": ["DE", "AT", "CH"],
         "posted_within_days": 7
       },
       "waterfall": { "strategy": "cheapest_first", "max_credits": 25 },
       "limit": 25
     }'
   ```

   **Python**

   ```python
   import os
   import requests


   resp = requests.post(
       "https://api.betterjobs.cc/v1/jobs/search",
       headers={
           "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}",
           "BetterJobs-Version": "2026-10-01",
       },
       json={
           "filters": {
               "title_or": ["Head of RevOps"],
               "country_code_or": ["DE", "AT", "CH"],
               "posted_within_days": 7,
           },
           "waterfall": {"strategy": "cheapest_first", "max_credits": 25},
           "limit": 25,
       },
   )
   resp.raise_for_status()
   print(resp.headers["X-Credits-Charged"], "credits charged,", resp.headers["X-Credits-Remaining"], "left")
   ```

   **TypeScript**

   ```ts
   const resp = await fetch('https://api.betterjobs.cc/v1/jobs/search', {
     method: 'POST',
     headers: {
       Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`,
       'BetterJobs-Version': '2026-10-01',
       'Content-Type': 'application/json',
     },
     body: JSON.stringify({
       filters: {
         title_or: ['Head of RevOps'],
         country_code_or: ['DE', 'AT', 'CH'],
         posted_within_days: 7,
       },
       waterfall: { strategy: 'cheapest_first', max_credits: 25 },
       limit: 25,
     }),
   });
   if (!resp.ok) throw new Error(`BetterJobs ${resp.status}`);
   console.log(resp.headers.get('X-Credits-Charged'), 'credits charged,', resp.headers.get('X-Credits-Remaining'), 'left');
   ```

1 credit = 1 unique job returned. Duplicates and empty searches are free. Run the same search again and the jobs you already have come back free, counted in `metadata.jobs_already_paid`.

> Free and Starter plans
>
> These plans search the BetterJobs index only. `cheapest_first` already skips providers your plan does not include. Naming a partner in `waterfall.providers` returns `403 plan_required`. See [Errors](https://docs.betterjobs.cc/platform/errors.md#plan_required).

## 4. Build your own query

Change the fields. The request updates live in every format, including a Clay HTTP column body and an MCP tool call.

Unknown filter fields return `400 unknown_filter`, never a silent empty result. All filters are listed in [Filters](https://docs.betterjobs.cc/platform/filters.md).

## Next steps

- [Authentication](https://docs.betterjobs.cc/getting-started/authentication.md): live and test keys, rotation.
- [The waterfall](https://docs.betterjobs.cc/concepts/waterfall.md): strategies, budget caps and partial results.
- [Credits and billing](https://docs.betterjobs.cc/concepts/credits-and-billing.md): what is charged and how to prove it.
- [Choose your path](https://docs.betterjobs.cc/getting-started/choose-your-path.md): Clay, agents, data pipelines or recruiting.
- [Search jobs API reference](/api/operations/searchjobs/)
