# Migrate from TheirStack

> How do I translate my TheirStack queries and fields to BetterJobs?

Source: https://docs.betterjobs.cc/guides/migrate-from-theirstack/

TheirStack and BetterJobs share a filter style: field names with suffixes such as `_or` and `_not`. Most queries translate line by line. The bigger changes are billing (re-reads are free) and the response shape (one canonical job with `sources[]`).

TheirStack is also one of the six providers behind BetterJobs. You can keep it in the mix and add the others with the same request.

### [TheirStack](https://docs.betterjobs.cc/providers/theirstack.md)

`theirstack`

Global job postings, technographics inferred from job text, buying intent and firmographics.

- Coverage

  Global job postings; no person data

- Discovery

  73% of jobs discovered the same day, 91% by the end of the next day

- Credits

  1 API credit per job, 3 per company

- Re-fetch

  Re-fetching the same job is billed again (filter discovered\_at\_gte)

Growth includes 2 partner providers; Pro and above include all six. GET /v1/providers shows what your plan enables.

Facts per TheirStack docs.

## What changes, in one table

| Topic                       | TheirStack (per TheirStack docs)                        | BetterJobs                                                                                                                                                             |
| --------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Sources                     | TheirStack’s own job corpus                             | BetterJobs index plus up to six providers, TheirStack included, merged                                                                                                 |
| Filter grammar              | `_or`, `_not`, `_gte` / `_lte`, `_max_age_days`         | `_or`, `_not`, `_gte` on `salary_min_gte`, plus `posted_within_days`. See [Filters](https://docs.betterjobs.cc/platform/filters.md)                                    |
| Job price                   | 1 API credit per job                                    | 1 credit per unique job                                                                                                                                                |
| Fetching the same job again | Billed again; filter on `discovered_at_gte` to avoid it | Free. Jobs you already paid for return at no charge                                                                                                                    |
| Company data                | 3 credits per company; technographics and firmographics | `GET /v1/companies/{domain}`: 1 credit, hiring profile only (`is_hiring`, `hiring_pulse`)                                                                              |
| Webhooks                    | `job.new`, `job.closed`, `company.new`                  | `job.opened`, `job.reposted`, `job.closed`, `job.updated`, `company.hiring_started`, `company.hiring_stopped`. See [Events](https://docs.betterjobs.cc/data/events.md) |
| Unknown filter names        | —                                                       | `400 unknown_filter`, never ignored                                                                                                                                    |

> BetterJobs has no technographics
>
> TheirStack infers technographics and buying intent from job text, per its docs. BetterJobs v1 preview returns jobs and hiring profiles only. If you use TheirStack for tech-stack data, keep that part of your integration. See [When not to use BetterJobs](https://docs.betterjobs.cc/resources/when-not-to-use.md).

## Translate a request

Your TheirStack job search body (illustrative; built from the field names and suffix grammar in TheirStack docs, so check exact names against your code):

```json
{
  "job_title_or": ["Head of RevOps", "Head of Revenue Operations"],
  "job_title_not": ["Intern"],
  "company_domain_or": ["acme-robotics.example", "northwind.example"],
  "remote": true,
  "discovered_at_gte": "2026-10-04T00:00:00Z",
  "limit": 25
}
```

The same search on BetterJobs:

**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"],
      "title_not": ["Intern"],
      "company_domain_or": ["acme-robotics.example", "northwind.example"],
      "remote": true,
      "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", "Head of Revenue Operations"],
            "title_not": ["Intern"],
            "company_domain_or": ["acme-robotics.example", "northwind.example"],
            "remote": True,
            "posted_within_days": 7,
        },
        "waterfall": {"strategy": "cheapest_first", "max_credits": 25},
        "limit": 25,
    },
    timeout=30,
)
resp.raise_for_status()
page = resp.json()
jobs, meta = page["data"], page["metadata"]
```

**TypeScript**

```ts
const res = 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', 'Head of Revenue Operations'],
      title_not: ['Intern'],
      company_domain_or: ['acme-robotics.example', 'northwind.example'],
      remote: true,
      posted_within_days: 7,
    },
    waterfall: { strategy: 'cheapest_first', max_credits: 25 },
    limit: 25,
  }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const { data: jobs, metadata } = await res.json();
```

### Filter by filter

| TheirStack pattern                         | BetterJobs filter    | Note                                                                                |
| ------------------------------------------ | -------------------- | ----------------------------------------------------------------------------------- |
| `job_title` + `_or`                        | `title_or`           | Sending `job_title_or` returns `400 unknown_filter` with a “Did you mean” hint.     |
| `job_title` + `_not`                       | `title_not`          |                                                                                     |
| `company_domain` + `_or`                   | `company_domain_or`  |                                                                                     |
| `seniority` + `_or`                        | `seniority_or`       | BetterJobs values: see [Taxonomies](https://docs.betterjobs.cc/data/taxonomies.md). |
| `remote`                                   | `remote`             | `null` or omitted = any. `false` = on-site or hybrid only.                          |
| `min_annual_salary_usd` + `_gte`           | `salary_min_gte`     | Compared in the job’s own currency, yearly. Not converted to USD.                   |
| `discovered_at_gte`, `..._max_age_days`    | `posted_within_days` | Relative days, counted from `first_seen_at`. No absolute date filter in v1 preview. |
| `_lte` on any field                        | none                 | Filter client-side for now.                                                         |
| filters on technographics or firmographics | none                 | Not in BetterJobs.                                                                  |

Everything goes inside a `filters` object. Routing, budget and paging sit next to it: `waterfall`, `limit`, `cursor`, `dry_run`.

## Map the fields

Field names in TheirStack responses and their BetterJobs equivalents. Rows show only fields with a confident one-to-one match.

| BetterJobs                | TheirStack              |
| ------------------------- | ----------------------- |
| `title`                   | `job_title`             |
| `company.domain`          | `company_domain`        |
| `location.remote`         | `remote`                |
| `seniority`               | `seniority`             |
| `salary`                  | `salary_string`         |
| `salary.min`              | `min_annual_salary_usd` |
| `posted_at`               | `date_posted`           |
| `first_seen_at`           | `discovered_at`         |
| `sources[].url`           | `url`                   |
| `sources[].first_seen_at` | `discovered_at`         |

Every BetterJobs field, with type and null meaning, is in the [field dictionary](https://docs.betterjobs.cc/data/field-dictionary.md).

### Response shape

TheirStack returns one record per job it found. BetterJobs returns one **canonical job** per real opening, merged from every source that saw it:

```json
{
  "id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB",
  "title": "Head of Revenue Operations",
  "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" },
  "posted_at": "2026-10-08T00:00:00Z",
  "first_seen_at": "2026-10-08T06:40:00Z",
  "salary": { "min": 110000, "max": 135000, "currency": "EUR", "period": "year", "origin": "declared" },
  "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", "url": "https://jobs.acme-robotics.example/revops-lead", "fields": ["salary", "seniority"] }
  ]
}
```

Illustrative, trimmed. When TheirStack saw the job, its record is listed in `sources[]` with `provider: "theirstack"` and the fields it contributed.

## Billing differences

1. **Re-reads are free.** Per TheirStack docs, fetching the same job again is billed again, which is why you filter on `discovered_at_gte`. On BetterJobs, a job you already paid for comes back free and is counted in `metadata.jobs_already_paid`. Overlapping windows cost nothing.

2. **Duplicates across providers are free.** If TheirStack and another source report the same opening, you pay 1 credit for the canonical job. `metadata.duplicates_merged` counts the folded records.

3. **Company lookups are a different product.** TheirStack charges 3 credits per company (per its docs) for company data. BetterJobs charges 1 credit per hiring profile: open jobs, `is_hiring`, `hiring_pulse`, top job families. No firmographics.

4. **Proof of charge.** `GET /v1/billing/ledger?job_id=...` shows when each job was charged and every free re-read.

TheirStack is a partner provider: Growth includes two partner providers, Pro and above include all six. To keep TheirStack in your results, check `GET /v1/providers` for `enabled_on_your_plan`. On Scale you can bring your own provider keys. Plans are on [Credits and billing](https://docs.betterjobs.cc/concepts/credits-and-billing.md).

## What changes in your code

1. **Base URL, auth and version header.** `https://api.betterjobs.cc/v1`, `Authorization: Bearer bj_live_...`, `BetterJobs-Version: 2026-10-01`. See [Authentication](https://docs.betterjobs.cc/getting-started/authentication.md).

2. **Wrap filters.** Move filters into `filters` and rename them with the table above.

3. **Drop the re-fetch guard.** Remove `discovered_at_gte` bookkeeping that only existed to avoid paying twice. Use `posted_within_days` with a day of overlap. See [Sync patterns](https://docs.betterjobs.cc/guides/sync-patterns.md).

4. **Key on the canonical `id`.** Store `job_...` ids. Keep the TheirStack id from `sources[].provider_job_id` only if you need to join old rows.

5. **Rename webhook handlers.** `job.new` becomes `job.opened`, `job.closed` stays `job.closed`. Add a no-op for `job.reposted`: it must not start outreach. `company.new` has no equivalent. See [Detect hiring changes](https://docs.betterjobs.cc/guides/detect-hiring-changes.md).

6. **Handle partial results.** A provider timeout returns `200` with `metadata.status: partial`. Log `metadata.providers.failed`; do not treat it as an error.

If downstream code expects TheirStack field names, adapt each job at the edge:

**curl**

```bash
curl -s 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"], "posted_within_days": 7}, "limit": 25}' \
| jq '[.data[] | {
    job_title: .title,
    company_domain: .company.domain,
    date_posted: .posted_at,
    discovered_at: .first_seen_at,
    remote: .location.remote,
    seniority: .seniority,
    url: ([.sources[].url | select(. != null)] | first)
  }]'
```

**Python**

```python
def to_theirstack_names(job: dict) -> dict:
    """Rename BetterJobs fields to the TheirStack names your code already reads."""
    return {
        "job_title": job["title"],
        "company_domain": job["company"]["domain"],
        "date_posted": job["posted_at"],        # may be null: use discovered_at then
        "discovered_at": job["first_seen_at"],
        "remote": job["location"]["remote"],    # null = unknown, not False
        "seniority": job["seniority"],
        "url": next((s["url"] for s in job["sources"] if s["url"]), None),
        # No min_annual_salary_usd: BetterJobs salary is in salary.currency per salary.period.
    }




rows = [to_theirstack_names(job) for job in jobs]
```

**TypeScript**

```ts
type Job = {
  title: string;
  company: { domain: string | null };
  posted_at: string | null;
  first_seen_at: string;
  location: { remote: boolean | null };
  seniority: string | null;
  sources: { url: string | null }[];
};


/** Rename BetterJobs fields to the TheirStack names your code already reads. */
function toTheirStackNames(job: Job) {
  return {
    job_title: job.title,
    company_domain: job.company.domain,
    date_posted: job.posted_at, // may be null: use discovered_at then
    discovered_at: job.first_seen_at,
    remote: job.location.remote, // null = unknown, not false
    seniority: job.seniority,
    url: job.sources.find((s) => s.url)?.url ?? null,
    // No min_annual_salary_usd: BetterJobs salary is in salary.currency per salary.period.
  };
}


const rows = jobs.map(toTheirStackNames);
```

## Pitfalls

> Salary is not converted to USD
>
> `min_annual_salary_usd` is an annual USD figure. BetterJobs `salary.min` is in `salary.currency` per `salary.period`, and `salary.origin` says whether it was `declared` or `inferred`. Convert yourself if you need USD.

- **`posted_within_days` uses first sighting.** It filters on `first_seen_at`, the closest match to TheirStack’s `discovered_at`, not `date_posted`.
- **`null` is unknown.** `location.remote: null` means no source said. It is not `false`.
- **Typos fail loudly.** Unknown filter names return `400 unknown_filter` instead of a wider, more expensive result.
- **Enum values differ.** Check `seniority_or` values against [Taxonomies](https://docs.betterjobs.cc/data/taxonomies.md) before you copy them over.

## Next

- [TheirStack provider page](https://docs.betterjobs.cc/providers/theirstack.md)
- [Filters](https://docs.betterjobs.cc/platform/filters.md) and [field dictionary](https://docs.betterjobs.cc/data/field-dictionary.md)
- [Find companies hiring](https://docs.betterjobs.cc/guides/find-companies-hiring.md)
