# Find companies hiring

> How do I find companies hiring for a role in a region?

Source: https://docs.betterjobs.cc/guides/find-companies-hiring/

Cost: 1 credit / unique jobDuplicates, already-paid jobs, empty pages and dry runs are free.

**Goal:** a list of companies hiring a Head of RevOps in Germany, Austria or Switzerland in the last 7 days, with the matching jobs under each company.

You search jobs, then group them by `company.domain`. One request covers every provider on your plan.

## Steps

1. **Estimate for free.** Send the search with `dry_run: true`. You get `expected_unique_jobs_range` and `credits_range`. Nothing is fetched or charged.

2. **Search with a cap.** Send the same search without `dry_run`. Set `waterfall.max_credits` so one page cannot charge more than you expect, and stop paging once the summed `metadata.credits_charged` reaches your run budget.

3. **Page through results.** Pass `next_cursor` back as `cursor` until it is `null`. Each page holds up to 100 jobs.

4. **Group by company.** Key each job on `company.domain`. Fall back to `company.id` when the domain is `null`.

5. **Optional: check each company.** Call `GET /v1/companies/{domain}` for `is_hiring` and `hiring_pulse`. That is 1 credit per profile.

## Estimate first

**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", "Head of Revenue Operations"],
      "country_code_or": ["DE", "AT", "CH"],
      "posted_within_days": 7
    },
    "waterfall": { "strategy": "cheapest_first" },
    "limit": 100,
    "dry_run": true
  }'
```

**Python**

```python
import os
import requests


API = "https://api.betterjobs.cc/v1"
HEADERS = {
    "Authorization": f"Bearer {os.environ['BETTERJOBS_API_KEY']}",
    "BetterJobs-Version": "2026-10-01",
}
FILTERS = {
    "title_or": ["Head of RevOps", "Head of Revenue Operations"],
    "country_code_or": ["DE", "AT", "CH"],
    "posted_within_days": 7,
}


resp = requests.post(
    f"{API}/jobs/search",
    headers=HEADERS,
    json={"filters": FILTERS, "waterfall": {"strategy": "cheapest_first"}, "limit": 100, "dry_run": True},
)
resp.raise_for_status()
print(resp.json()["estimate"])  # free
```

**TypeScript**

```ts
const API = 'https://api.betterjobs.cc/v1';
const headers = {
  Authorization: `Bearer ${process.env.BETTERJOBS_API_KEY}`,
  'BetterJobs-Version': '2026-10-01',
  'Content-Type': 'application/json',
};
const filters = {
  title_or: ['Head of RevOps', 'Head of Revenue Operations'],
  country_code_or: ['DE', 'AT', 'CH'],
  posted_within_days: 7,
};


const res = await fetch(`${API}/jobs/search`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ filters, waterfall: { strategy: 'cheapest_first' }, limit: 100, dry_run: true }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log((await res.json()).estimate); // free
```

The estimate looks like this (illustrative):

```json
{
  "request_id": "req_3Kd8PwZ1uY",
  "estimate": {
    "expected_unique_jobs_range": { "min": 40, "max": 75 },
    "providers_planned": ["betterjobs", "theirstack", "techmap"],
    "credits_range": { "min": 40, "max": 75 }
  }
}
```

## Search, page and group

**curl**

```bash
# First page. Repeat with "cursor": "<next_cursor>" until next_cursor is null.
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" \
  -H "Idempotency-Key: 7b8f2c4e-1a3d-4f5b-9c6e-0d2a4b6c8e1f" \
  -d '{
    "filters": {
      "title_or": ["Head of RevOps", "Head of Revenue Operations"],
      "country_code_or": ["DE", "AT", "CH"],
      "posted_within_days": 7
    },
    "waterfall": { "strategy": "cheapest_first", "max_credits": 100 },
    "limit": 100
  }'
```

Group the saved pages with `jq`:

```bash
jq -s '[.[].data[]] | group_by(.company.domain // .company.id)
  | map({company: .[0].company, jobs: map({id, title, location})})' page-*.json
```

**Python**

```python
import uuid
from collections import defaultdict




def search_all(filters: dict, budget: int) -> list[dict]:
    """Stops when the run has spent `budget` credits. max_credits caps each page only."""
    jobs, cursor, spent = [], None, 0
    while spent < budget:
        body = {
            "filters": filters,
            "waterfall": {"strategy": "cheapest_first", "max_credits": min(100, budget - spent)},
            "limit": 100,
        }
        if cursor:
            body["cursor"] = cursor
        resp = requests.post(
            f"{API}/jobs/search",
            headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
            json=body,
        )
        resp.raise_for_status()
        page = resp.json()
        if page["metadata"]["status"] == "partial":
            print("partial page, failed providers:", page["metadata"]["providers"]["failed"])
        jobs += page["data"]
        spent += page["metadata"]["credits_charged"]
        cursor = page["next_cursor"]
        if cursor is None:
            break
    return jobs




companies: dict[str, dict] = defaultdict(lambda: {"company": None, "jobs": []})
for job in search_all(FILTERS, budget=100):
    key = job["company"]["domain"] or job["company"]["id"]
    companies[key]["company"] = job["company"]
    companies[key]["jobs"].append({"id": job["id"], "title": job["title"], "p_real": job["p_real"]})


for key, entry in sorted(companies.items(), key=lambda kv: -len(kv[1]["jobs"])):
    print(entry["company"]["name"], key, len(entry["jobs"]))
```

**TypeScript**

```ts
type Job = {
  id: string;
  title: string;
  p_real: number;
  company: { id: string; name: string; domain: string | null };
};


async function searchAll(budget: number): Promise<Job[]> {
  // Stops when the run has spent `budget` credits. max_credits caps each page only.
  const jobs: Job[] = [];
  let cursor: string | null = null;
  let spent = 0;
  do {
    const res = await fetch(`${API}/jobs/search`, {
      method: 'POST',
      headers: { ...headers, 'Idempotency-Key': crypto.randomUUID() },
      body: JSON.stringify({
        filters,
        waterfall: { strategy: 'cheapest_first', max_credits: Math.min(100, budget - spent) },
        limit: 100,
        ...(cursor ? { cursor } : {}),
      }),
    });
    if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
    const page = await res.json();
    if (page.metadata.status === 'partial') console.warn('partial page', page.metadata.providers.failed);
    jobs.push(...page.data);
    spent += page.metadata.credits_charged;
    cursor = page.next_cursor;
  } while (cursor && spent < budget);
  return jobs;
}


const companies = new Map<string, { company: Job['company']; jobs: Pick<Job, 'id' | 'title' | 'p_real'>[] }>();
for (const job of await searchAll(100)) {
  const key = job.company.domain ?? job.company.id;
  const entry = companies.get(key) ?? { company: job.company, jobs: [] };
  entry.jobs.push({ id: job.id, title: job.title, p_real: job.p_real });
  companies.set(key, entry);
}
console.log([...companies.values()].sort((a, b) => b.jobs.length - a.jobs.length));
```

## Example response excerpt

One canonical job from the search (illustrative, trimmed). Three sources saw it; you pay for it once.

```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 },
      "seniority": "lead",
      "salary": { "min": 110000, "max": 135000, "currency": "EUR", "period": "year", "origin": "declared" },
      "status": "open",
      "p_real": 0.94,
      "sources": [
        { "provider": "betterjobs", "provider_job_id": "bj_idx_5521907", "fields": ["title", "description", "apply_url", "location", "employment_type", "posted_at"] },
        { "provider": "theirstack", "provider_job_id": "ts_88213377", "fields": ["salary", "seniority"] },
        { "provider": "techmap", "provider_job_id": "tm_3f9a2c71", "fields": ["job_family"] }
      ]
    }
  ],
  "next_cursor": "cur_8fJ2kQ",
  "metadata": {
    "status": "complete",
    "credits_charged": 1,
    "jobs_already_paid": 0,
    "duplicates_merged": 2,
    "providers": { "tried": ["betterjobs", "techmap", "theirstack"], "hit": ["betterjobs", "techmap", "theirstack"], "failed": [] }
  }
}
```

After grouping (illustrative):

```json
[
  {
    "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" },
    "jobs": [{ "id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB", "title": "Head of Revenue Operations", "p_real": 0.94 }]
  }
]
```

## Check a company (optional)

A search tells you who posted matching jobs. A company profile tells you whether the company is hiring overall and in which direction.

**curl**

```bash
curl https://api.betterjobs.cc/v1/companies/acme-robotics.example \
  -H "Authorization: Bearer $BETTERJOBS_API_KEY" \
  -H "BetterJobs-Version: 2026-10-01"
```

**Python**

```python
profile = requests.get(f"{API}/companies/acme-robotics.example", headers=HEADERS)
profile.raise_for_status()
p = profile.json()
print(p["is_hiring"]["value"], p["hiring_pulse"]["direction"], p["open_jobs_count"])
```

**TypeScript**

```ts
const profile = await fetch(`${API}/companies/acme-robotics.example`, { headers });
if (!profile.ok) throw new Error(`${profile.status} ${await profile.text()}`);
const p = await profile.json();
console.log(p.is_hiring.value, p.hiring_pulse.direction, p.open_jobs_count);
```

```json
{
  "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" },
  "open_jobs_count": 42,
  "is_hiring": { "value": true, "confidence": 0.96, "basis": "42 open jobs corroborated by 4 sources in the last 30 days" },
  "hiring_pulse": { "direction": "up", "open_jobs_30d_change": 9 }
}
```

## Credit cost

| Call                                        | Cost                                                                                              |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `POST /v1/jobs/search` with `dry_run: true` | Free                                                                                              |
| `POST /v1/jobs/search`                      | 1 credit per unique job returned. Duplicates, jobs you already paid for and empty pages are free. |
| `GET /v1/companies/{domain}`                | 1 credit per profile                                                                              |

A company with 30 matching jobs costs 30 credits through search. If you already have an account list and only need yes or no, `GET /v1/companies/{domain}` at 1 credit each is cheaper. See [Credits and billing](https://docs.betterjobs.cc/concepts/credits-and-billing.md).

## Pitfalls

> Group on domain, not on name
>
> Company names differ across sources (“Acme Robotics”, “Acme Robotics GmbH”). Group on `company.domain`. When it is `null`, use `company.id`; never drop the job.

> null is not false
>
> `is_hiring.value: null` means unknown, not “not hiring”. Do not remove an account from a list because of a `null`. See [Provenance and confidence](https://docs.betterjobs.cc/concepts/provenance-and-confidence.md).

- **`posted_within_days` counts from first seen.** It matches jobs first seen within that many days. A job posted earlier but discovered late still matches. Check `posted_at` if you need the employer’s date.
- **Partial pages are normal.** A provider timeout gives `200` with `metadata.status: partial`. You pay only for returned jobs. Re-run later to fill gaps; jobs you already paid for come back free.
- **`max_credits` stops results.** When the cap is hit, the page stops early. Raise the cap or narrow the filters.
- **Before outbound, prefer corroborated jobs.** Use `p_real`, or the `consensus` strategy. See [Choose a strategy](https://docs.betterjobs.cc/guides/choose-a-strategy.md).
- **Unknown filter fields fail.** A typo returns `400 unknown_filter` with the field in `param`. See [Filters](https://docs.betterjobs.cc/platform/filters.md).

## Next

- [Detect hiring changes](https://docs.betterjobs.cc/guides/detect-hiring-changes.md): get told when these companies open or close jobs.
- [Pagination](https://docs.betterjobs.cc/platform/pagination.md) and [Companies](https://docs.betterjobs.cc/data/companies.md).
- API reference: [Search jobs](/api/operations/searchjobs/), [Get a company](/api/operations/getcompany/).
