---
title: "Delta feed — created, updated, withdrawn and delisted, in change order."
method: GET
path: "/v1/changes"
tags: ["jobs"]
---

# Delta feed — created, updated, withdrawn and delisted, in change order.

`GET /v1/changes`

## Request

- Base URL: `https://api.jobopportunitiesapi.org`
- URL: `https://api.jobopportunitiesapi.org/v1/changes`
- Auth: HTTP bearer

Poll this instead of re-reading the ledger. Pass `since` on the first
call and the returned `next_since` from then on; it is a keyset cursor
of `<timestamp>|<uuid>`, so no row is lost to a shared timestamp.

**New: `change: "withdrawn"`.** A row can leave the served set without
being closed by the employer — because we judged it no longer meets the
employer-direct promise this API is sold on. Until now those rows simply
stopped being mentioned, which meant a mirror built from this feed kept
serving them indefinitely. `withdrawn` says so explicitly.

Treat it exactly as you treat `delisted`: stop serving the row. The
difference is only in why. `delisted` means the vacancy is gone;
`withdrawn` means the vacancy may well still exist, but we are no longer
willing to stand behind the link — most often because it points at a
government or aggregator portal rather than at the employer.

A withdrawn row keeps its id. If we later judge it servable again it
returns as `updated`, so a mirror keyed on id needs no special handling.

Roles an employer pushes to us directly follow the same
rule: pausing, unlisting or deleting one is `withdrawn` (its job
carries no `closed_at`: it is not a closure), closing it or letting
its deadline pass is `delisted` with `closed_reason`
`employer_closed` or `deadline_passed`, and publishing it again
is `updated` under the same id.

If your integration switches on `change`, add the new value before
upgrading. An unknown-value branch that ignores it will keep serving
rows we have withdrawn.

**What `updated` means.** `updated` is emitted when a job's visible
content changed (title, description, company, location, salary,
employment type, apply link, ...), when a closed job was re-opened, or
when a withdrawn job was restored. Re-verifying that a job is still
open does NOT produce an event: `last_seen_at` is a verification
timestamp, not a change timestamp, and `created` / `updated`
therefore no longer fire for every live job every day. A re-opened job
keeps its original id and `first_seen_at` and arrives as `updated`,
never as `created`: upsert on `id`.

**Billing follows the same rule.** Each event is one record, and since
the content clock went live (18 Oct 2026) a verification heartbeat is
not an event, so it is not billed. Content changes made before the
clock went live were not recorded: if you need a baseline, page
/v1/jobs once, then follow this feed.

## Query parameters

- `country` string
- `since` string, required
- `limit` integer

## Response `200`

A page of changes.

- object
  - `count` integer
  - `data` object[]
    - `change` 'created' | 'updated' | 'withdrawn' | 'delisted': `created` first seen since your cursor · `updated` its content moved · `withdrawn` still open, but no longer served by us — stop serving it · `delisted` the vacancy itself is gone.
    - `job` Job
      - `agency` 'direct' | 'agency' | 'umbrella': Who the employer of record is: `direct` — the advertiser; `agency` — a temporary-work agency (the engagement is then employee, per EU and national law, and contract_category describes the contract with the agency — permanent agency contracts exist); `umbrella` — an umbrella / portage company. OMITTED when not established (`?agency=unknown`). No confidence key by design.
      - `apply_url` string
      - `attribution` string: Present only on roles an employer pushed to us directly: show the listing with these words. Absent on every other row.
      - `canonical_url` string: Present only on roles an employer pushed to us directly: the role's own page, where people apply and which search engines should index. Link to it, not to our page. Absent on every other row.
      - `category` string
      - `category_confidence` number, nullable: Classifier confidence in `category`. Below 0.6 no category is published at all, so this is always null or >= 0.6.
      - `city` string
      - `closed_at` string, nullable: JOA records as the closure date the day its daily verification confirmed that an advert had gone. Before late September 2026 that verification ran in large catch-up batches, so closure dates recorded before then show when JOA confirmed a closure, not when the employer closed the role; daily verification applies from late September 2026.
      - `closed_reason` 'expired_upstream' | 'not_seen' | 'employer_closed' | 'deadline_passed' | 'null', nullable
      - `company` string, required
      - `company_logo` string
      - `company_slug` string, required
      - `contract_category` string[]: The contract kind the posting establishes (db/232): `permanent` is returned only when the posting STATES an open-ended contract (CDI, unbefristet, tempo indeterminato, indefinido, ...); `fixed_term` ends on a date or fixed period; `project_based` ends on completion of a defined project; `seasonal` when the source says seasonal; `apprenticeship` is a statutory work-and-study contract; `internship` includes traineeships; `other` is a STATED form none of these represent (zero-hours, casual, per diem, Minijob, Werkstudent, ...). An ARRAY (dual-option adverts), main option first. OMITTED when the posting states none — most postings; filterable as `?contract_category=unknown`. For vendors the category only describes the term of the engagement.
      - `contract_category_confidence` number: Present ONLY when field_sources.contract_category is `inferred`: the measured precision of the rule, 0..1.
      - `country` string
      - `country_confidence` 'high' | 'low': Present ONLY when WE filled the country (`field_sources.country` is `inferred` after a gazetteer-plus-model agreement, or `llm_inferred`). `high`: gazetteer and a language model agree. `low`: a language model alone read the location text (`llm_inferred`; measured precision about 95%, see field_sources.country). Omitted on every other row.
      - `description` string: The full advert text. Present ONLY when the request set include_description=true, and only on the 94.3% of rows that have one (1,861,942 live rows). Personal email addresses are removed: an email address that identifies one person (a name-based address such as jane.doe@...) is replaced with `[personal contact removed]`. Company mailboxes stay in the text: role mailboxes (jobs@, careers@, hr@, info@ and their equivalents in other languages). Phone numbers are not changed on this path. A role an employer listed with us directly keeps its text exactly as the employer published it. Nothing else in the text, `apply_url` and every other field are unchanged. `description_length` counts the stored text, before this replacement. The bulk `GET /v1/export` removes every email address and phone number instead.
      - `description_length` number: Size of description in bytes. Always present: 0 means the row carries no advert text, exactly as has_description:false does. Computed with octet_length, which reads the length out of the TOAST header without fetching the text — the text itself still needs include_description=true.
      - `description_truncated` boolean: True while the stored advert text is known to be cut short at its source: harvested under an old 4,000-character limit and not yet re-fetched, or longer than the 100,000-character safety ceiling. JOA does not shorten the text it serves; this flag says the ledger's own copy is incomplete. Always present.
      - `employer_website` string: The employer's own site as THIS posting states it — hiringOrganization.url or sameAs, or the ATS's own field (db/144). A different claim from `website` on /v1/companies, which is verified against the domain's own page.
      - `employment_type` string: One of Full-time, Part-time, Contract, Temporary, Internship. A row that already carried one keeps it; otherwise this is the employmentType the page itself declares (db/144), normalised to the same five values (db/220; a declared value that maps to none of them is not shown). Either way the value is the source's own, which is why field_sources.employment_type is published whenever the key is present, and ?employment_type= filters on exactly this value. UNCHANGED and frozen since the contract fields (engagement, contract_category, agency, working_schedule) shipped: existing integrations keep byte-identical behaviour. Rough old-to-new reading: Contract/Temporary mostly correspond to engagement=vendor or contract_category=fixed_term, Internship to contract_category=internship — but the new fields come from what the posting STATES, so they are populated independently, not translated from this one.
      - `engagement` string[]: The employment relationship the posting establishes (db/232, plan v2.1): `employee` — an employment contract with some employer of record running payroll (the advertiser, a temp agency or an umbrella company); `vendor` — a commercial or civil-law service relationship (freelance, B2B, 1099, C2C, Brazilian PJ: the person invoices); `other` — a stated hybrid national form that everyday understanding puts on neither side (Polish umowa zlecenie / umowa o dzieło, Italian co.co.co., Spanish TRADE, umbrella/portage arrangements, statutory non-employment internships such as the French stage, volunteering). The raw national term is retained internally. An ARRAY because some adverts offer two arrangements ("CLT ou PJ", "UoP lub B2B"): main option first, at most 3 values, usually exactly 1. OMITTED when the posting does not establish it — which is most postings; absence is filterable as `?engagement=unknown` and never means "employee by default".
      - `engagement_confidence` number: Present ONLY when field_sources.engagement is `inferred`: the measured precision of the rule that established the value, 0..1. A published value carries no confidence — it is the source's words.
      - `expires_at` string: When the vacancy stops being valid, from either source: the timestamp the radar PROVED it dead (upstream_expired_at), or the date the posting itself states (db/144, schema.org validThrough). A row that already carried the first keeps it. Absent while the vacancy is live. Same shape as every other timestamp here — a date from the posting renders as midnight of that date.
      - `field_sources` FieldSources, required: Per-field provenance. `published` means the value was carried by the source. `inferred` means WE produced it — it may be right, it is not a quotation. `absent` means no value, which is a different failure from a guess and is reported as such. `category` and `seniority` are ALWAYS `inferred`: both are read off the job title by a classifier, never from a field the employer filled in. `salary` is `published` when the source stated it in a field (`salary_source: structured`), and `inferred` when we read it out of the advert text (`salary_source: parsed_description`). It is never an estimate: the projection refuses to emit AI salary predictions at all, so a modelled figure cannot reach this API by any route. `region`, `expires_at`, `description_length`, `source_requisition_id`, `employer_website`, `salary_max`, `salary_currency` and `salary_period` were added with db/144, which moved the fields parsed out of the source's own page into a side table (`joa.listing_extract`). The payload coalesces the listing column with the extracted one, preferring whatever the row already carried, and these keys say whether a value is there: a value that came from the source is `published`, a missing one is `absent`. Every key on this object is ALWAYS present on every job — the vocabulary is the value, not the presence — so a client can bind the whole shape.
        - `agency` 'published' | 'inferred' | 'absent', required
        - `category` 'published' | 'inferred' | 'absent', required
        - `contract_category` 'published' | 'inferred' | 'absent', required
        - `country` 'published' | 'inferred' | 'llm_inferred' | 'absent', required: How the job's `country` was obtained. `published`: the source's own location carried it. `inferred`: a rule of ours (gazetteer, state code), or the gazetteer and a language model agreeing (`country_confidence: "high"`, measured precision 99.5%). `llm_inferred`: LOW TRUST. A language model read the location text and named the country with no second signal; `country_confidence` is `"low"` and the measured precision of this class is about 95% (hand-checked sample of 120 rows; 98% on an independent model check of 400 rows), against about 99% for the other classes. Filter on `field_sources.country` in your own code to keep only the higher-trust values. `absent`: the job has no country.
        - `description` 'published' | 'inferred' | 'absent', required
        - `description_length` 'published' | 'inferred' | 'absent', required: Computed from the same octet_length as description, so the two cannot disagree: published when the row carries text, absent when it does not.
        - `employer_website` 'published' | 'inferred' | 'absent', required: Provenance of employer_website. Like source_requisition_id it only ever comes from the page the sweep parsed, so it is published or absent, never inferred.
        - `employment_type` 'published' | 'inferred' | 'absent', required
        - `engagement` 'published' | 'inferred' | 'absent', required: Provenance of the engagement array (db/232): `published` when the source's own field or page stated the relationship, `inferred` when an audited deterministic rule of ours established it (the payload then also carries engagement_confidence), `absent` when the posting does not establish it — the value key is then omitted and `?engagement=unknown` selects the row. Never inferred from silence.
        - `expires_at` 'published' | 'inferred' | 'absent', required: Provenance of expires_at: published when the row carries one, from either source — see the Job schema — and absent while the vacancy is live.
        - `language` string: "detected" when the row publishes a language - our detector's reading of the advert text, not the source's declaration - and "absent" while the row's language is null (the key was omitted then until 17 Oct 2026). It never reads published or inferred.
        - `location` 'published' | 'inferred' | 'absent', required
        - `posted_at` 'published' | 'inferred' | 'absent', required: published when the source stated the date or timestamp. Workday publishes a date only (see posted_at_precision): its rows read inferred until the posting's own start date has been read from Workday (on the next re-verification, about daily), because the list API only says "Posted N Days Ago" and we derive the date from that.
        - `region` 'published' | 'inferred' | 'absent', required: published when the row carries a region, absent when it does not. It does NOT separate the region the source declared from the ones db/078 derives from the location text or the city gazetteer, and it does not separate the listing column from the value extracted off the page (db/144) — read it as "we established a region", not as a quotation.
        - `remote` 'published' | 'inferred' | 'absent', required
        - `salary` 'published' | 'inferred' | 'absent', required
        - `salary_currency` 'published' | 'inferred' | 'absent', required: Provenance of salary_currency; the same verdict as salary.
        - `salary_max` 'published' | 'inferred' | 'absent', required: Provenance of salary_max, and always the same verdict as salary: published only when the source stated the figure in a field of its own, inferred when we read it out of the advert text, absent when there is no figure.
        - `salary_period` 'published' | 'inferred' | 'absent', required: Provenance of salary_period; the same verdict as salary.
        - `seniority` 'published' | 'inferred' | 'absent', required
        - `source_requisition_id` 'published' | 'inferred' | 'absent', required: Provenance of source_requisition_id. The value only ever comes from the page the verify sweep parsed (db/144), so this is published or absent, never inferred.
        - `source_type` 'published' | 'inferred' | 'absent', required: `inferred` when the value is the provider's default class; `published` when the row itself was classified upstream. TODAY THIS IS ALWAYS `inferred`: the row-level column is null on every live listing, so the provider's class is the only path taken. `published` is reserved for when per-row classification lands. Do not build a filter that expects it to appear.
        - `working_schedule` 'published' | 'inferred' | 'absent', required
      - `first_seen_at` string: When the vacancy first entered this ledger. Populated on 100% of rows, unlike posted_at which the source often omits — so this is the field to sort or backfill by when you need every row to have a date.
      - `has_description` boolean, required
      - `id` string, uuid, required
      - `language` string, nullable: ISO 639-1 code of the advert text, detected automatically from the description (or the title when there is no description). null when the text is mixed, too short or the detection is not confident. Measured precision >= 99% on a random sample; null does not mean English.
      - `language_basis` string, nullable: What evidence the detection used: "text" (the description), "title" (the advert text was missing or too short), "title+country" (the title is English, the advert text is missing or too short, and the job is in the US, GB, CA, AU, IE, NZ, SG or IN; measured precision 99.4% against description-based labels on 28,797 rows) or "none". null whenever language is null. field_sources.language reports "detected" when a value is published - our detection of the advert text, never the source's declaration.
      - `language_confidence` number, nullable: The detector's own confidence (0.00-1.00) for the published language; null whenever language is null. A label is stored only at >=0.80 with a description and >=0.90 title-only; labels with language_basis "title+country" carry a score between 0.50 and 0.85 (the title score, capped at 0.85), so they are always below the other bases.
      - `last_verified_at` string, required: When we last confirmed this vacancy still exists at its source.
      - `location` string
      - `posted_at` string
      - `posted_at_precision` 'date': Present only when the source publishes a calendar date and no time (Workday). posted_at then carries that date at a fixed 12:00:00Z, so the day is the same in every timezone from UTC-11 to UTC+11; the time part carries no information. Omitted when posted_at is a full timestamp.
      - `provider_type` 'employer_ats' | 'government' | 'direct' | 'aggregator', required: The provider-level class, in the original vocabulary. Kept so a query written against it still resolves.
      - `region` string: State/province code where one could be established — US two-letter codes today, absent on every non-US row and on cities that resolve to more than one state. db/078 resolves it from the source's declared region first, then a state named in the free-text location, then an unambiguous city; db/144 adds the region the page itself declares (schema.org addressRegion) where the listing column has none. field_sources.region reports published whenever the row carries one and does not separate those derivations, so read it as "we established a region", not as a quotation.
      - `relevance` object: Present only on rows served with `sort=relevance`. `score` is an ordering-only integer (comparable within one response, never across queries); `tier` is the match class: exact, synonym, abbreviation or typo.
        - `score` integer
        - `tier` 'exact' | 'synonym' | 'abbreviation' | 'typo'
      - `remote` 'remote' | 'hybrid' | 'on_site'
      - `remote_inferred` boolean, required: **Always present.** True when we derived `remote` rather than read it. It used to be omitted when false, which made a stated value and an absent one identical on the wire.
      - `salary_currency` string: ISO code, as the source states it. Same sources as salary_min.
      - `salary_max` number: The top of the range, same sources as salary_min. Absent when the source states a single figure or none at all.
      - `salary_min` number: The source's own figure. A row that already carried one keeps it; otherwise this is the baseSalary the page declares, or the ATS's salaryRange (db/144). Absent — not null — when there is no figure, which is the case for most employers.
      - `salary_min_annual_eur` number: salary_min converted to an annual EUR figure, so a row quoting USD/hour and one quoting GBP/year are comparable. NULL when the period or the currency is unrecognised — never guessed, because reading an hourly rate as a salary is wrong by a factor of 2080. Converted with the indicative rates in /v1/meta/freshness, not a settlement rate.
      - `salary_period` string: hour, day, week, month or year, as the source states it. Same sources as salary_min.
      - `salary_source` 'structured' | 'parsed_description': Where the figure came from. `structured` is a field the source itself published. `parsed_description` is a real figure quoted in the advert text that WE read out and normalised — we chose the number and the period, so `field_sources.salary` reports it as `inferred`, never `published`. This marker describes the LISTING row only. A figure that arrived from the extracted side table (db/144) is the source's own baseSalary or salaryRange field, and `field_sources.salary` reports it as `published` while salary_source itself is absent.
      - `seniority` string
      - `slug` string, required
      - `source` string, required: The provider id, e.g. greenhouse, company_site, workday. The full published list is /public/providers; eures, arbeitsagentur and france_travail were named here until 2026-08-15, when they became discovery-only and left that list.
      - `source_requisition_id` string: The identifier the SOURCE uses for this vacancy — an ATS job id, a Workday requisition id, a schema.org identifier — so a support ticket or a buyer's own ATS lookup can quote it. Absent on every row the verify sweep has not extracted yet (db/144); joa.listings has no column for it, which is why the payload carried it for 0% of rows before.
      - `source_type` 'ats' | 'career_site' | 'public_agency' | 'aggregator' | 'agency' | 'unknown', required: Per-row provenance where the source has been classified per row, otherwise the provider's class. Snapshot of the distribution on 5 Aug 2026: ats 1,063,129, career_site 672,526, public_agency 320,727; the current split by source class is in /public/coverage (sources). RESERVED VALUES: `aggregator` and `agency` are accepted by the filter but currently match no rows. Every aggregator source is marked non-redistributable, so that inventory never enters the ledger at all, and no provider is classified `agency` yet. Filtering on either returns an empty page — that is the data, not a fault.
      - `status` 'live' | 'closed', required
      - `title` string, required
      - `upstream_expired_at` string: When the radar proved the vacancy dead, as opposed to when we removed it. Still the radar's column and nothing else; `expires_at` is the key that also carries the posting's own validThrough date.
      - `working_schedule` 'full_time' | 'part_time' | 'flexible': The stated schedule. `flexible` covers adverts offering either schedule. OMITTED when not established (`?working_schedule=unknown`). Distinct from, and independent of, the frozen employment_type field above.
      - `working_schedule_confidence` number: Present ONLY when field_sources.working_schedule is `inferred`.
  - `next_since` string

## Other responses

- `401`: Missing, unknown, revoked or expired key. All four are reported identically.
- `402`: The plan's monthly RECORD allowance is spent. Distinct from 429: a 429 means slow down and retry, a 402 means the licence is used up until the allowance resets. The monthly window is the CALENDAR month in UTC: it resets on the 1st of the next month at 00:00 UTC, and the body gives the exact instant. Do not retry before then. Every metered response carries `X-RateLimit-Records-Remaining` and `X-RateLimit-Records-Reset` (the reset instant, Unix seconds), so this is visible long before it happens.
- `403`: This endpoint is not included in your plan. `X-JOA-Required-Feature` names the missing entitlement — `delta_feed` or `bulk_export`. See https://jobopportunitiesapi.org/api for the tiers that include it.
- `429`: Over your plan's per-minute or per-day limit. `Retry-After` says how long (seconds to the next minute boundary, or to UTC midnight on a daily breach). The body says exactly when the limit resets (`resets_at`, `resets_at_text`) and, when a higher plan exists, links a same-key upgrade (`upgrade_url`).

---

[API](https://skmtc.dev/jobopportunitiesapi/apis/job-opportunities-api.md) · [All operations](https://skmtc.dev/jobopportunitiesapi/apis/job-opportunities-api/llms.txt) · [OpenAPI document](https://skmtc.dev/jobopportunitiesapi/apis/job-opportunities-api/revisions/b5ef9709a0c9?raw)
