# Get the feed

> A time-windowed feed of newly indexed postings, built for keeping a local copy in sync.

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

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

Every job we indexed within a time window, oldest first. This is the endpoint
to poll when you're mirroring the dataset — it's windowed by time rather than
by offset, so unlike [`/v1/jobs`](/docs/api-reference/jobs-list) it can't drift
under you as new rows land. See [syncing the feed](/docs/guides/feed-sync) for
the full pattern.

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

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

let qs = new URLSearchParams({ since: "1h", limit: "1000" });
while (true) {
  const res = await fetch(`https://api.hyperjobs.io/v1/jobs/feed?${qs}`, { headers });
  const { data, next_cursor } = await res.json();
  jobs.push(...data);
  if (!next_cursor) break;
  qs = new URLSearchParams({ since: "1h", limit: "1000", cursor: next_cursor });
}
```

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

headers = {"Authorization": f"Bearer {os.environ['HYPERJOBS_KEY']}"}
params = {"since": "1h", "limit": 1000}
jobs = []

while True:
    res = requests.get(
        "https://api.hyperjobs.io/v1/jobs/feed",
        params=params, headers=headers, timeout=30,
    )
    res.raise_for_status()
    payload = res.json()
    jobs.extend(payload["data"])
    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: `15m`, `2h`, `24h`, `7d`. Filters on when **we** indexed
    the job — not its posting date.

    `m` means **minutes**. There's no week or month unit — write `7d` or `30d`.
    An unparseable value is a `400` naming the parameter, not a silent fallback.
  </ParamField>
  <ParamField query="created_gte" type="ISO 8601 date">
    Absolute alternative to `since`: jobs indexed on/after this instant. When
    both are sent, `created_gte` wins. Use it to resume from the exact
    `created_at` of the last row you stored.
  </ParamField>
  <ParamField query="limit" type="integer" default="200">
    Results per page, 1 to **1,000**. Out-of-range values — including
    negative numbers — are a `400`.
  </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. Omitted by default — the key is absent, not null.
    Think hard before turning this on for a 1,000-row feed.
  </ParamField>
</Fields>

<Note>
  That's the whole vocabulary. The feed takes **no filters** — no `skill`, no
  `country`, no `source` — and any other parameter is a `400`. Filter
  client-side after you've pulled the window, or use
  [`/v1/jobs`](/docs/api-reference/jobs-list) with `created_gte` when you need
  a filtered slice rather than a sync.
</Note>

<Note>
  **The feed excludes expired postings and duplicates**, exactly like
  `/v1/jobs` — feed rows and index rows are the same population. (A pre-release
  draft served expired rows here; that asymmetry never shipped.) Your mirror
  doesn't accumulate dead jobs from the feed; to
  remove the ones that die *after* you ingested them, poll
  [`/v1/jobs/expired`](/docs/api-reference/jobs-expired).
</Note>

## Response

<Fields>
  <ResponseField name="since" type="string">
    The window that was applied. `since` is validated (a bad value is a
    `400`), so the echo is trustworthy.
  </ResponseField>
  <ResponseField name="count" type="integer">
    The number of rows in `data` — this page, not a total.
  </ResponseField>
  <ResponseField name="data" type="Job[]">
    The postings, **oldest first** — ascending by `created_at`, the moment we
    first indexed each job. Same shape as everywhere else — see
    [the Job object](/docs/objects/job).
  </ResponseField>
  <ResponseField name="next_cursor" type="string | null">
    Pass it back as `?cursor=` for the next page. `null` means you've drained
    the window.
  </ResponseField>
</Fields>

```json title="Response"
{
  "since": "2h",
  "count": 143,
  "data": [
    {
      "id": "greenhouse:4820193",
      "title": "Senior Data Engineer",
      "company": { "slug": "figma", "name": "Figma", "domain": "figma.com" },
      "location": { "remote": false, "countries": ["United States"], "cities": ["San Francisco"] },
      "employment_type": ["FULL_TIME"],
      "source": "greenhouse",
      "posted_at": "2026-07-16T09:41:02.000Z",
      "created_at": "2026-07-16T09:48:15.000Z",
      "expires_at": null,
      "updated_at": "2026-07-16T09:48:15.000Z"
    }
    // …142 more rows
  ],
  "next_cursor": "eyJzIjoiMjAyNi0wNy0xNiAwOTo0ODoxNSIsImlkIjoiZ3JlZW5ob3VzZTo0ODIwMTkzIn0"
}
```

<Warning title="The window is on index time, not posting date">
  `since=1h` means "indexed in the last hour". A job posted three days ago that
  we discovered ten minutes ago **is** in the window — `posted_at` can be much
  older than `created_at`. That's the property a sync loop wants (you never
  miss a late-discovered job), but don't treat the feed as "posted in the last
  hour".
</Warning>

## Paging

Ascending order plus a keyset cursor means the window can't overflow: request,
follow `next_cursor`, repeat until it's `null`. The draft-era failure mode —
`count === limit` meaning the oldest rows were silently cut with no way to
reach them — doesn't exist here. Rows land in the order we indexed them, so each
page picks up exactly where the last one ended, and new arrivals during paging
appear at the end rather than shifting anything under you.

Once you've drained a window, store the last row's `created_at` and use
`created_gte` on the next poll instead of a fixed `since` — no overlap, no gap.

<Note>
  There's no `Cache-Control` header on the feed, despite it being the most
  cacheable thing here. Don't expect a CDN or a `fetch` cache to help — if you
  poll on an interval, keep your own copy.
</Note>

## 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. |

Validation is strict: `?since=banana` is a `400`, not a silent
24-hour default — a typo can no longer make your sync loop quietly re-ingest a
day of jobs every poll.

## 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="Expired jobs" icon="Trash2" href="/docs/api-reference/jobs-expired">
    The purge feed: ids to delete from your mirror.
  </Card>
  <Card title="Modified jobs" icon="Pencil" href="/docs/api-reference/jobs-modified">
    Jobs whose tracked fields changed — the upsert side of a sync.
  </Card>
  <Card title="Pagination" icon="ChevronsRight" href="/docs/pagination">
    Cursors vs. offsets, and why the index drifts.
  </Card>
</CardGroup>
