# Canonical jobs

> What is a canonical job, and how are duplicates from different providers merged?

Source: https://docs.betterjobs.cc/concepts/canonical-jobs/

A **canonical job** is one real opening at one company. BetterJobs builds it from every provider record that describes that opening. You get one row per opening, not one row per provider.

## Posting vs job

A **posting** is one listing somewhere: an ATS page, a job board ad, an aggregator copy. One opening often has many postings. The employer lists it on its careers page. Boards copy it. Two months later it is re-listed with a fresh date.

A **job** is the opening behind those postings. BetterJobs returns jobs. Each job lists the postings it was built from in `sources[]`:

```json
{
  "id": "job_01JC8X4M2Q7RV3T9KD5W6YH0AB",
  "title": "Head of Revenue Operations",
  "company": { "id": "cmp_4Rk7TzP1aQ", "name": "Acme Robotics", "domain": "acme-robotics.example" },
  "repost_count": 0,
  "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"] }
  ]
}
```

Illustrative record, trimmed. The full version is in [Search jobs](/api/operations/searchjobs/).

Three providers saw this opening. You pay for one job.

## How duplicates are found

Records are compared on the signals that identify an opening:

- **Company.** Resolved to one canonical company (`company.id`, `company.domain`).
- **Title.** Normalized, so “Head of RevOps” and “Head of Revenue Operations” can match.
- **Location.** City, region and country.
- **Posting URLs.** The origin URL and `apply_url`, when sources report them.
- **Provider ids.** A provider’s own `provider_job_id` keeps its record attached to the same job across requests.

When records match, they merge. Each canonical field takes its value from the source best placed to supply it. `sources[].fields` tells you which source supplied which field.

> Why providers disagree on what a duplicate is
>
> Providers dedup differently on their own. Per Reqbeat docs, it keeps one row per company + title + country. Per Coresignal docs, its Base API returns one row per source with an `isDuplicate` flag. Per Techmap’s field reference, records carry `isDuplicate` too. BetterJobs applies one dedup across all of them, so the same opening from two providers is one job, not two.

## Reposts

When an employer re-lists the same opening, it stays the same canonical job. `repost_count` goes up. `id` does not change.

- A watch delivers `job.reposted`, not `job.opened`. Do not re-trigger outbound on it. See [Events](https://docs.betterjobs.cc/data/events.md).
- `job.reposted` is free. Only `job.opened` costs a credit.
- A high `repost_count` is a signal worth reading: the role may be hard to fill, or the listing may be evergreen.

## Ids are stable

`id` (`job_...`) is stable across sources and requests. The same opening returns the same id from tomorrow’s search, from a different strategy, and from `GET /v1/jobs/{id}`.

Use it as your primary key:

- Upsert on `id` in your database or CRM. See [Sync patterns](https://docs.betterjobs.cc/guides/sync-patterns.md).
- A job you already paid for is free on every later read. `metadata.jobs_already_paid` counts them. See [Credits and billing](https://docs.betterjobs.cc/concepts/credits-and-billing.md).
- `company.id` (`cmp_...`) works the same way for companies.

Do not key on `sources[].provider_job_id` or a posting URL. Those belong to one posting, and a job can gain or lose postings over time.

## What merging costs

Nothing. `metadata.duplicates_merged` counts provider records folded into canonical jobs. They are free.

## Related

- [Provenance and confidence](https://docs.betterjobs.cc/concepts/provenance-and-confidence.md): reading `sources[]` and `p_real`
- [Freshness and lifecycle](https://docs.betterjobs.cc/concepts/freshness-and-lifecycle.md): `first_seen_at`, `status`, `closed_reason`
- [Field dictionary](https://docs.betterjobs.cc/data/field-dictionary.md#field-job-repost_count)
