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
}idis the monotonic position of the event in the feed. Events are always returned in ascendingidorder.change_typeis one ofcreated,updated,closed, orreopened.next_cursoris always present — pass it back as thecursorquery parameter on your next request. When there are no new events, it simply echoes the cursor you sent.has_moretells 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:
- Request
/api/v1/changes?cursor=<stored>. - Process
data, then storenext_cursor. - If
has_moreistrue, request again immediately with the new cursor. - If
has_moreisfalse, 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.
Recommended sync pattern
To build and maintain a full local mirror:
- Backfill — either page through
/api/v1/jobswith your filters to seed the current open inventory, or replay the full change history via/api/v1/changeswith no cursor (which also captures closed/historical jobs). - Store the cursor — after the backfill, persist the latest
next_cursor. - Consume forever — run the poll loop above on your own interval. Apply
created/updated/reopenedevents by upserting the job record (useinclude=jobor fetch by id) andclosedevents 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.