# Get modified jobs

> Jobs whose tracked fields changed within a window — the upsert side of a sync.

Source: https://hyperjobs.io/docs/api-reference/jobs-modified

<EndpointHeader method="GET" path="/v1/jobs/modified" />

Jobs that **changed** — not new, not dead, changed. When a posting's salary
band moves, its apply URL is swapped, or it flips to remote, it won't reappear
in [`/v1/jobs/feed`](/docs/api-reference/jobs-feed) (that's windowed on first
index). This endpoint catches those edits: full [Job objects](/docs/objects/job),
oldest change first, each carrying `modified_at` and a `modified_fields` array
telling you exactly what moved. See [syncing the feed](/docs/guides/feed-sync)
for where it fits.

<CodeGroup>
```bash title="cURL"
curl -G https://api.hyperjobs.io/v1/jobs/modified \
  -H "Authorization: Bearer $HYPERJOBS_KEY" \
  --data-urlencode "since=24h"
```

```js title="Node"
const headers = { Authorization: `Bearer ${process.env.HYPERJOBS_KEY}` };

let qs = new URLSearchParams({ since: "24h" });
while (true) {
  const res = await fetch(`https://api.hyperjobs.io/v1/jobs/modified?${qs}`, { headers });
  const { data, next_cursor } = await res.json();
  for (const job of data) upsert(job); // replace your row wholesale by id
  if (!next_cursor) break;
  qs = new URLSearchParams({ since: "24h", cursor: next_cursor });
}
```

```python title="Python"
import os, requests

headers = {"Authorization": f"Bearer {os.environ['HYPERJOBS_KEY']}"}
params = {"since": "24h"}

while True:
    res = requests.get(
        "https://api.hyperjobs.io/v1/jobs/modified",
        params=params, headers=headers, timeout=30,
    )
    res.raise_for_status()
    payload = res.json()
    for job in payload["data"]:
        upsert(job)  # replace your row wholesale by id
    if not payload["next_cursor"]:
        break
    params["cursor"] = payload["next_cursor"]
```
</CodeGroup>

## Query parameters

<Fields>
  <ParamField query="since" type="&lt;number&gt;&lt;m|h|d&gt;" default="24h">
    Relative window: jobs whose tracked fields **changed** within it. `15m`,
    `2h`, `24h`, `7d` — `m` is minutes. A bad value is a `400`.
  </ParamField>
  <ParamField query="modified_gte" type="ISO 8601 date">
    Absolute alternative to `since`: changes on/after this instant. Use it to
    resume from the exact `modified_at` of the last row you processed.
  </ParamField>
  <ParamField query="limit" type="integer" default="200">
    Results per page, 1 to **1,000**.
  </ParamField>
  <ParamField query="cursor" type="string">
    Opaque keyset cursor from the previous page's `next_cursor`. Page until it
    comes back `null`.
  </ParamField>
  <ParamField query="description_format" type="&quot;text&quot; | &quot;html&quot;" default="omitted">
    Include the description. Worth considering here even if you skip it on the
    feed — `description` is a tracked field, and without this flag a
    description-only change hands you a Job with no visible difference.
  </ParamField>
</Fields>

No filters beyond the window — anything else is a `400`. Like `/v1/jobs` and
the feed, the population excludes duplicates and expired postings; a job whose
change *was* its expiry shows up in
[`/v1/jobs/expired`](/docs/api-reference/jobs-expired) instead.

## Tracked fields

Only changes to these fields register — `modified_fields` draws from exactly
this vocabulary:

`title` · `organization_name` · `locations` · `countries` · `employment_type` ·
`work_arrangement` · `seniority` · `salary_min` · `salary_max` ·
`salary_currency` · `salary_unit` · `apply_url` · `url` · `remote` ·
`department` · `description`

A re-scrape that changes nothing tracked — or only touches enrichment like
skills or taxonomies — does not bump `modified_at` and won't surface here.

## Response

<Fields>
  <ResponseField name="count" type="integer">
    Rows in `data` — this page, not a total.
  </ResponseField>
  <ResponseField name="data" type="Job[]">
    Full Job objects, **oldest change first** — ascending by `modified_at`.
    Every row carries `modified_at` and `modified_fields`.
  </ResponseField>
  <ResponseField name="next_cursor" type="string | null">
    Pass it back as `?cursor=`; `null` means the window is drained.
  </ResponseField>
</Fields>

```json title="Response"
{
  "count": 37,
  "data": [
    {
      "id": "ashby:7c1f2a94-3e17-4d8b-9c05-1f6ab2e40d33",
      "title": "Staff Backend Engineer",
      "company": { "slug": "linear", "name": "Linear", "domain": "linear.app" },
      "location": { "remote": true, "countries": ["United States"], "continent": "North America" },
      "employment_type": ["FULL_TIME"],
      "salary": {
        "min": 190000,
        "max": 240000,
        "currency": "USD",
        "unit": "YEAR",
        "annual_min": 190000,
        "annual_max": 240000,
        "summary": "$190,000 – $240,000 a year"
      },
      "apply_url": "https://jobs.ashbyhq.com/linear/7c1f2a94…",
      "source": "ashby",
      "posted_at": "2026-07-08T14:02:11.000Z",
      "modified_at": "2026-07-16T08:30:05.000Z",
      "modified_fields": ["salary_max", "apply_url"],
      "created_at": "2026-07-08T14:10:22.000Z",
      "updated_at": "2026-07-16T08:30:05.000Z"
    }
    // …36 more rows — full Job objects, other fields elided here
  ],
  "next_cursor": null
}
```

## The sync loop role

The feed adds, [`/v1/jobs/expired`](/docs/api-reference/jobs-expired) deletes —
this endpoint is the **update**. On each poll cycle, upsert every returned Job
into your mirror keyed on `id`, replacing the stored row wholesale: the payload
is the complete current state, so there's no patch to compute. Ignoring this
endpoint doesn't break a mirror, it *stales* it — jobs keep their day-one
salary and apply URL for as long as they live, which for a job board means
sending applicants to links that have since been swapped out.

`modified_fields` is there so you can react selectively — re-run salary
normalization only when a `salary_*` field appears, re-verify links only on
`apply_url` — rather than reprocessing every row. Overlapping windows are
harmless: the upsert is idempotent. The full pattern is in
[syncing the feed](/docs/guides/feed-sync).

## Errors

| Status | Cause |
| --- | --- |
| `400` | Invalid parameter or value — the body names the `param` and carries a `hint`. |
| `401` | Missing, unknown, or revoked key. |
| `429` | [Rate limit](/docs/rate-limits) or monthly quota exceeded. |
| `500` | Server error. Retry with backoff. |

## Next

<CardGroup cols={2}>
  <Card title="Syncing the feed" icon="RefreshCw" href="/docs/guides/feed-sync">
    Snapshot, poll, expire — the whole mirroring pattern.
  </Card>
  <Card title="Get the feed" icon="Rss" href="/docs/api-reference/jobs-feed">
    The add side: newly indexed jobs, ascending.
  </Card>
  <Card title="Expired jobs" icon="Trash2" href="/docs/api-reference/jobs-expired">
    The delete side: ids to purge from your mirror.
  </Card>
  <Card title="The Job object" icon="Braces" href="/docs/objects/job">
    Every field on the payloads this endpoint returns.
  </Card>
</CardGroup>
