Syncing with the changes feed

Keep a local copy of the job catalog in sync by polling the append-only change 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

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

{
  "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

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

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

After processing each page, 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:

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

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

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

# 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

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}:

curl "https://www.puesto.dev/api/v1/changes?cursor=4822&include=job" \
  -H "Authorization: Bearer sk_your_key_here"
{
  "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.

To build and maintain a full local mirror:

  1. Backfill — either page through /api/v1/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.

On this page