# Get expired jobs

> The purge feed — ids of jobs that expired within a window, for deleting from your mirror.

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

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

The delete side of a sync loop. While [`/v1/jobs/feed`](/docs/api-reference/jobs-feed)
tells you what to add, this endpoint tells you what to remove: the ids and
expiry timestamps of every job that expired within the window, oldest expiry
first. The payload is deliberately minimal — no titles, no companies, just
enough to run `DELETE WHERE id IN (…)` against your copy. See
[syncing the feed](/docs/guides/feed-sync) for where this fits.

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

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

let qs = new URLSearchParams({ since: "24h" });
while (true) {
  const res = await fetch(`https://api.hyperjobs.io/v1/jobs/expired?${qs}`, { headers });
  const { data, next_cursor } = await res.json();
  dead.push(...data.map((row) => row.id));
  if (!next_cursor) break;
  qs = new URLSearchParams({ since: "24h", cursor: next_cursor });
}
// DELETE FROM jobs WHERE id IN dead
```

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

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

while True:
    res = requests.get(
        "https://api.hyperjobs.io/v1/jobs/expired",
        params=params, headers=headers, timeout=30,
    )
    res.raise_for_status()
    payload = res.json()
    dead.extend(row["id"] for row in 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: jobs that **expired** within it. `15m`, `2h`, `24h`, `7d`
    — `m` is minutes. A bad value is a `400`.
  </ParamField>
  <ParamField query="expired_gte" type="ISO 8601 date">
    Absolute alternative to `since`: expiries on/after this instant. Use it to
    resume from the exact `expired_at` of the last row you processed.
  </ParamField>
  <ParamField query="limit" type="integer" default="1000">
    Results per page, 1 to **5,000** — the cap is higher than the other feeds
    because the payload is just ids and timestamps.
  </ParamField>
  <ParamField query="cursor" type="string">
    Opaque keyset cursor from the previous page's `next_cursor`. Page until it
    comes back `null`.
  </ParamField>
</Fields>

Any other parameter — including `description_format`, which has nothing to
attach to here — is a `400`.

## Response

<Fields>
  <ResponseField name="count" type="integer">
    Rows in `data` — this page, not a total.
  </ResponseField>
  <ResponseField name="data" type="{ id, expired_at }[]">
    Expiries, **oldest first** — ascending by `expired_at`. Each row is just
    the job `id` and when it expired.
  </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": 2,
  "data": [
    { "id": "greenhouse:4118377", "expired_at": "2026-07-15 18:00:41" },
    { "id": "workable:J7F3K2", "expired_at": "2026-07-15 21:12:09" }
  ],
  "next_cursor": null
}
```

<Warning title="`expired_at` is not ISO 8601">
  It comes back as `YYYY-MM-DD HH:MM:SS` — a space, no `T`, no zone suffix.
  The instant is **UTC**. `new Date("2026-07-15 18:00:41")` parses it in the
  *local* zone in most runtimes, silently shifting every timestamp — append
  `Z` (and swap the space for `T` where the parser is strict) before parsing:

  ```js
  const when = new Date(row.expired_at.replace(" ", "T") + "Z");
  ```
</Warning>

## The sync loop role

A mirror built on the feed alone only ever grows. Postings close, and nothing
in the add-side feed revisits them — so on each poll cycle, after ingesting
[`/v1/jobs/feed`](/docs/api-reference/jobs-feed) and upserting
[`/v1/jobs/modified`](/docs/api-reference/jobs-modified), pull this endpoint
with the same window and **delete (or mark dead) every returned id locally**.
Ids are stable across all three endpoints, so the delete is a plain key match.

Use a window comfortably wider than your poll interval (or resume with
`expired_gte` from the last stored `expired_at`) — the operation is
idempotent, so overlap costs nothing, but a gap leaves zombie postings your
users can still click. The full pattern, including the initial snapshot, is in
[syncing the feed](/docs/guides/feed-sync).

<Note>
  An expired job may remain retrievable at
  [`/v1/jobs/{id}`](/docs/api-reference/jobs-retrieve) while it's still in the
  pool — with `expires_at` in the past. That's deliberate: this endpoint is the
  signal to purge, not the last moment the record exists.
</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. |

## 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="Modified jobs" icon="Pencil" href="/docs/api-reference/jobs-modified">
    The update side: jobs whose tracked fields changed.
  </Card>
  <Card title="Deduplication" icon="Copy" href="/docs/guides/deduplication">
    Why ids are stable, and what a duplicate fold means for your keys.
  </Card>
</CardGroup>
