# Migrate from JobsPipe

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

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

JobsPipe and BetterJobs look alike from the outside: both search jobs with `POST /v1/jobs/search` and bill per job. The differences are inside: BetterJobs fans the search out to JobsPipe and up to six other sources, merges the results into canonical jobs, and never bills the same job twice.

JobsPipe is one of the six providers behind BetterJobs, so its postings can still reach you through the waterfall.

### [JobsPipe](https://docs.betterjobs.cc/providers/jobspipe.md)

`jobspipe`

Normalized job postings from 30+ sources with 12 months of history.

- Sources

  30+ sources incl. Greenhouse, Lever, Ashby, Workday, Indeed, LinkedIn

- History

  12 months

- Freshness

  Under 6h on Builder, under 1h on Scale

- Credits

  1 credit per job; one credit buys a job for the rest of the calendar month

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

Facts per JobsPipe docs.

## What changes, in one table

| Topic           | JobsPipe (per JobsPipe docs)                                          | BetterJobs                                                                                   |
| --------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Search endpoint | `POST /v1/jobs/search`                                                | `POST /v1/jobs/search` on `https://api.betterjobs.cc`                                        |
| Sources         | 30+ sources incl. Greenhouse, Lever, Ashby, Workday, Indeed, LinkedIn | BetterJobs index plus up to six providers, JobsPipe included, merged                         |
| Job price       | 1 credit per job                                                      | 1 credit per unique job                                                                      |
| Paying again    | One credit buys a job for the rest of the calendar month              | A job you already paid for returns free; the ledger shows `already_paid`                     |
| Ghost postings  | `ghost_score`                                                         | `p_real`: probability the job is real, from cross-source corroboration                       |
| Taxonomies      | ISCO-08, ISIC, ESCO                                                   | `job_family` strings and a fixed `seniority` enum. No ISCO, ISIC or ESCO codes in v1 preview |
| History         | 12 months                                                             | `posted_within_days` up to 365, `include_closed: true` for closed jobs                       |
| Freshness       | Under 6h on Builder, under 1h on Scale                                | Published at GA. Each job carries `first_seen_at`, `last_seen_at`, `last_verified_at`        |

## Translate a request

The path is the same. Change the base URL, the auth header and the body: BetterJobs puts every filter inside one `filters` object and routing next to it in `waterfall`.

```text
Before:  POST <JobsPipe base URL>/v1/jobs/search   + your JobsPipe filters and key
After:   POST https://api.betterjobs.cc/v1/jobs/search
         Authorization: Bearer bj_live_...
         BetterJobs-Version: 2026-10-01
         { "filters": { ... }, "waterfall": { ... }, "limit": 100 }
```

**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": ["Senior Data Engineer", "Staff Data Engineer"],
      "country_code_or": ["NL", "DE"],
      "seniority_or": ["senior", "lead"],
      "remote": true,
      "posted_within_days": 14
    },
    "waterfall": { "strategy": "cheapest_first", "max_credits": 100 },
    "limit": 100
  }'
```

**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": ["Senior Data Engineer", "Staff Data Engineer"],
            "country_code_or": ["NL", "DE"],
            "seniority_or": ["senior", "lead"],
            "remote": True,
            "posted_within_days": 14,
        },
        "waterfall": {"strategy": "cheapest_first", "max_credits": 100},
        "limit": 100,
    },
    timeout=30,
)
resp.raise_for_status()
page = resp.json()
jobs, meta = page["data"], page["metadata"]
print(meta["credits_charged"], "charged,", meta["jobs_already_paid"], "already paid (free)")
```

**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: ['Senior Data Engineer', 'Staff Data Engineer'],
      country_code_or: ['NL', 'DE'],
      seniority_or: ['senior', 'lead'],
      remote: true,
      posted_within_days: 14,
    },
    waterfall: { strategy: 'cheapest_first', max_credits: 100 },
    limit: 100,
  }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const { data: jobs, metadata } = await res.json();
console.log(metadata.credits_charged, 'charged,', metadata.jobs_already_paid, 'already paid (free)');
```

Rewrite each JobsPipe filter you use as one of the BetterJobs filters on [Filters](https://docs.betterjobs.cc/platform/filters.md). A filter name BetterJobs does not know returns `400 unknown_filter` with the name in `error.param`, so a missed rename fails on the first call instead of returning a wider result.

Every BetterJobs search response states its cost in `metadata.credits_charged`, plus the `X-Credits-Charged` and `X-Credits-Remaining` headers.

## Map the fields

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

| BetterJobs                | JobsPipe        |
| ------------------------- | --------------- |
| `title`                   | `job_title`     |
| `seniority`               | `seniority`     |
| `salary`                  | `salary_usd`    |
| `posted_at`               | `date_posted`   |
| `first_seen_at`           | `discovered_at` |
| `last_seen_at`            | `last_seen_at`  |
| `last_verified_at`        | `verified_at`   |
| `closed_reason`           | `closed_reason` |
| `sources[].url`           | `url`           |
| `sources[].first_seen_at` | `discovered_at` |
| `sources[].last_seen_at`  | `last_seen_at`  |

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

> ghost\_score and p\_real point in opposite directions
>
> A high `ghost_score` flags a likely ghost posting. A high `p_real` means the job is likely real. The scales are computed differently, so `1 - ghost_score` is not `p_real`. Re-tune your thresholds on your own data.

> salary\_usd is not salary
>
> BetterJobs `salary` is an object: `min`, `max`, `currency`, `period` and `origin` (`declared` or `inferred`). Amounts stay in the posting’s currency. Convert to USD yourself if your code expects `salary_usd`.

## Billing differences

1. **Charged once, not once a month.** Per JobsPipe docs, one credit buys a job for the rest of the calendar month. BetterJobs charges 1 credit the first time a job reaches you; later reads of that job in searches or `GET /v1/jobs/{id}` are free.

2. **Duplicates across sources are free.** If JobsPipe and another source report the same opening, you pay for one canonical job. `metadata.duplicates_merged` counts the folded records.

3. **Empty pages and estimates are free.** `dry_run: true` returns `expected_unique_jobs_range` and `credits_range` without fetching.

4. **Proof of charge.** `GET /v1/billing/ledger?job_id=...` lists the original charge and every free re-read.

JobsPipe is a partner provider: Growth includes two partner providers, Pro and above include all six. `GET /v1/providers` shows whether your plan enables `jobspipe`. 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, key and version header.** See [Authentication](https://docs.betterjobs.cc/getting-started/authentication.md).

2. **Wrap filters.** Move them into `filters` and rename them to BetterJobs names.

3. **Rename response fields.** Use the table above, or the adapter below at the edge of your code.

4. **Replace taxonomy codes.** Map your ISCO-08 or ESCO lists to `job_family_or` and `seniority_or` values. See [Taxonomies](https://docs.betterjobs.cc/data/taxonomies.md).

5. **Drop month-boundary logic.** Code that avoided re-fetching across months to save credits is no longer needed.

6. **Read provenance.** `sources[]` shows which sources saw each job; the JobsPipe record appears with `provider: "jobspipe"`.

If downstream code reads JobsPipe names, adapt each job once:

**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": ["Senior Data Engineer"], "posted_within_days": 14}, "limit": 100}' \
| jq '[.data[] | {
    job_title: .title,
    date_posted: .posted_at,
    discovered_at: .first_seen_at,
    last_seen_at: .last_seen_at,
    verified_at: .last_verified_at,
    seniority: .seniority,
    closed_reason: .closed_reason,
    url: ([.sources[].url | select(. != null)] | first),
    p_real: .p_real
  }]'
```

**Python**

```python
def to_jobspipe_names(job: dict) -> dict:
    """Rename BetterJobs fields to the JobsPipe names your code already reads."""
    return {
        "job_title": job["title"],
        "date_posted": job["posted_at"],          # may be null: use discovered_at then
        "discovered_at": job["first_seen_at"],
        "last_seen_at": job["last_seen_at"],
        "verified_at": job["last_verified_at"],
        "seniority": job["seniority"],
        "closed_reason": job["closed_reason"],    # filled | expired | removed | unknown | None
        "url": next((s["url"] for s in job["sources"] if s["url"]), None),
        "p_real": job["p_real"],                  # not ghost_score: re-tune thresholds
        # No salary_usd: use job["salary"] (currency, period, origin).
    }




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

**TypeScript**

```ts
type Job = {
  title: string;
  posted_at: string | null;
  first_seen_at: string;
  last_seen_at: string;
  last_verified_at: string | null;
  seniority: string | null;
  closed_reason: 'filled' | 'expired' | 'removed' | 'unknown' | null;
  sources: { url: string | null }[];
  p_real: number;
};


/** Rename BetterJobs fields to the JobsPipe names your code already reads. */
function toJobsPipeNames(job: Job) {
  return {
    job_title: job.title,
    date_posted: job.posted_at, // may be null: use discovered_at then
    discovered_at: job.first_seen_at,
    last_seen_at: job.last_seen_at,
    verified_at: job.last_verified_at,
    seniority: job.seniority,
    closed_reason: job.closed_reason,
    url: job.sources.find((s) => s.url)?.url ?? null,
    p_real: job.p_real, // not ghost_score: re-tune thresholds
    // No salary_usd: use job.salary (currency, period, origin).
  };
}


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

## Pitfalls

- **Same path, different host.** Change the base URL and the key together. A key BetterJobs does not recognize returns `401` [`unauthorized`](https://docs.betterjobs.cc/platform/errors.md#unauthorized).
- **`closed_reason` values are a fixed enum.** BetterJobs uses `filled`, `expired`, `removed`, `unknown`, or `null` while open. Map JobsPipe values you stored before.
- **Seniority values differ.** Check your `seniority` filters against [Taxonomies](https://docs.betterjobs.cc/data/taxonomies.md) before copying them.
- **`null` is unknown.** `last_verified_at: null` means never verified at origin, not “dead”.

## Next

- [JobsPipe provider page](https://docs.betterjobs.cc/providers/jobspipe.md)
- [Provenance and confidence](https://docs.betterjobs.cc/concepts/provenance-and-confidence.md): `p_real` and `sources[]`.
- [Sync patterns](https://docs.betterjobs.cc/guides/sync-patterns.md)
