Source: https://www.puesto.dev/docs/changes-feed
Markdown source: https://www.puesto.dev/docs/changes-feed.md
Description: Keep a local copy of the job catalog in sync by polling the append-only change feed.

# Syncing with the changes feed



The changes feed (`GET /api/v1/changes`) is an **append-only log of job change
events**. Every time a job is created, updated, closed, or reopened, an event is
appended with a monotonically increasing integer `id`. By remembering the last
id you processed and asking for everything after it, you can keep a local copy
of the catalog in sync without re-crawling.

## The feed model [#the-feed-model]

A successful `GET /api/v1/changes` response has this shape:

```json
{
  "data": [
    {
      "id": 4821,
      "job_id": 12345,
      "change_type": "created",
      "changed_at": "2026-06-30T08:15:00+00:00"
    },
    {
      "id": 4822,
      "job_id": 11876,
      "change_type": "closed",
      "changed_at": "2026-06-30T08:17:42+00:00"
    }
  ],
  "next_cursor": "4822",
  "has_more": true,
  "limit": 100
}
```

* **`id`** is the monotonic position of the event in the feed. Events are
  always returned in ascending `id` order.
* **`change_type`** is one of `created`, `updated`, `closed`, or `reopened`.
* **`next_cursor`** is **always present** — pass it back as the `cursor` query
  parameter on your next request. When there are no new events, it simply
  echoes the cursor you sent.
* **`has_more`** tells you whether to keep paging immediately (see below).

## Initial sync: replaying history [#initial-sync-replaying-history]

Omit `cursor` (or send `cursor=0`) to replay the full feed from the very
beginning:

```bash
curl "https://www.puesto.dev/api/v1/changes?limit=500" \
  -H "Authorization: Bearer sk_your_key_here"
```

Keep requesting with each response's `next_cursor` until `has_more` is `false`
and you have consumed the entire history.

## Persisting the cursor [#persisting-the-cursor]

After processing each page, &#x2A;*durably store `next_cursor`** (a database row, a
file — anywhere that survives restarts). It is the only state you need: on the
next poll, resume exactly where you left off:

```bash
curl "https://www.puesto.dev/api/v1/changes?cursor=4822&limit=500" \
  -H "Authorization: Bearer sk_your_key_here"
```

Because the feed is append-only and the cursor is just a position, re-running a
page is safe — you may process an event twice, but you can never miss one, as
long as you only advance the stored cursor after processing succeeds.

## The poll loop [#the-poll-loop]

`has_more` is `true` when the page was full (`data.length === limit`), which
means more events are likely waiting:

1. Request `/api/v1/changes?cursor=<stored>`.
2. Process `data`, then store `next_cursor`.
3. If `has_more` is `true`, request again **immediately** with the new cursor.
4. If `has_more` is `false`, you are caught up — sleep for your own polling
   interval (for example a few minutes), then go back to step 1.

Note that a final page whose size exactly equals `limit` reports
`has_more: true`; the follow-up request then returns an empty `data` array with
your cursor echoed back, and you fall through to the sleep.

## Filtering by change type [#filtering-by-change-type]

Narrow the feed to the event kinds you care about with `types` — repeat the
parameter or comma-separate values:

```bash
# Only new and closed jobs
curl "https://www.puesto.dev/api/v1/changes?cursor=4822&types=created,closed" \
  -H "Authorization: Bearer sk_your_key_here"
```

The cursor semantics are unchanged: `next_cursor` still advances through the
underlying feed, skipping events of other types.

## Embedding job records [#embedding-job-records]

By default each event carries only the `job_id`. Set `include=job` to embed the
current job record (descriptions omitted, company attached) with each change,
saving a round trip to `/api/v1/jobs/{id}`:

```bash
curl "https://www.puesto.dev/api/v1/changes?cursor=4822&include=job" \
  -H "Authorization: Bearer sk_your_key_here"
```

```json
{
  "data": [
    {
      "id": 4823,
      "job_id": 12399,
      "change_type": "created",
      "changed_at": "2026-06-30T09:02:11+00:00",
      "job": {
        "id": 12399,
        "title": "Senior Software Engineer",
        "country_code": "US",
        "workplace": "remote",
        "posted_at": "2026-06-30T08:55:00+00:00",
        "company": {
          "id": 67,
          "name": "Acme Inc.",
          "website_url": "https://acme.example",
          "logo_url": null
        }
      }
    }
  ],
  "next_cursor": "4823",
  "has_more": false,
  "limit": 100
}
```

The embedded `job` is the job's **current** state (not a snapshot at change
time). It is `null` in the rare case the job record is unavailable.

## Recommended sync pattern [#recommended-sync-pattern]

To build and maintain a full local mirror:

1. **Backfill** — either page through [`/api/v1/jobs`](/docs/api/jobs/search-jobs)
   with your filters to seed the current open inventory, **or** replay the full
   change history via `/api/v1/changes` with no cursor (which also captures
   closed/historical jobs).
2. **Store the cursor** — after the backfill, persist the latest `next_cursor`.
3. **Consume forever** — run the poll loop above on your own interval. Apply
   `created`/`updated`/`reopened` events by upserting the job record (use
   `include=job` or fetch by id) and `closed` events by marking the job closed.

Filtering the feed by source is not yet available; filter client-side on the
embedded job's `source_id` if you need it.

