id as your primary key — it’s stable across updates, so every write is a plain upsert.
Initial backfill
Page through the entire feed once with cursor pagination. New scans use a stable scan by default (stable_scan: true): pages follow immutable creation time, so jobs updated or expired mid-backfill can’t shift records across page boundaries — no skips, no duplicates.
Three rules make the backfill robust:
- Persist
next_cursorafter every successful batch so a crash resumes mid-stream instead of restarting from page one. - Paginate serially — one in-flight request per cursor. Cursors aren’t designed for parallel fan-out.
- Send filters on the first request. The cursor embeds filters and sort. A continuation may reduce
batch_sizewhen fewer included jobs or less wallet balance is available, but cannot increase it; start a new scan to change filters or increase the batch.
has_more is false.
The SDK’s
iter_jobs handles cursor pagination for you but doesn’t expose a resume point across process restarts. For a one-off backfill that’s usually fine; for a backfill you need to resume after a crash, use the raw HTTP version and checkpoint next_cursor yourself.Incremental sync
Run on a schedule — 15–60 minutes is a healthy range. More frequent runs add wallet usage without much fresh data. Each run:- Read your stored
last_run_started_at. Recordnowasthis_run_started_atbefore the first request. - Call
/feedwithupdated_after = last_run_started_at - 15m. The 15-minute overlap protects against clock skew and late-arriving postings. - Page through with
cursoruntilhas_moreisfalse. Upsert each job byid. - After the loop succeeds, persist
this_run_started_atas the newlast_run_started_at.
updated_after, not posted_after — posted_after only catches newly posted jobs, while updated_after also catches edits to jobs you already hold. Compare the returned updated_at against what you stored and skip the write when it hasn’t changed.
updated_after reached the clients in 4.0.0. On 3.x and earlier, feed.iter_jobs cannot express it — use the raw HTTP version below, or upgrade.next_cursor as well as the watermark, so a
crash resumes mid-scan. The client’s iter_jobs restarts the scan instead —
fine for a 15-minute delta, worth the raw loop for a long backfill.
Handling deletions
/feed only returns active jobs, so jobs that expire simply stop appearing — it never tells you what disappeared. Sweep them with GET /api/jobs/expired on the same schedule as the incremental sync:
cursor, and mark every returned id as expired in your store.
Backoff and rate limits
- Configure a client response timeout of at least 120 seconds. The feed may spend up to 90 seconds querying the search backend before returning a retryable
503. - Retry only
503and429, with bounded backoff. On503, wait the seconds named inRetry-After(currently5) before retrying — the search backend’s circuit breaker reopens within ~30s. - On
400(invalid cursor), drop the cursor and restart pagination — don’t loop on the same value. - On
409 feed_cursor_restart_required, the cursor is a legacy non-stable scan that reached the deep-pagination boundary. Discard it and restart with{"stable_scan": true, "batch_size": 1000}, then send only each returned cursor on serial continuation requests. - Watch
X-Credits-Balanceto alert before you run dry.
Managed job sources
POST /api/jobs/feed/managed streams jobs from a private set of employers you’ve added in the Jobo portal, rather than the whole catalogue. It shares the same batch shape, cursor semantics, and metering as the main feed, with a few differences:
- There is no
sourcesfilter — you choose the companies in the Jobo portal, so filtering by their ATS provider on top of that is redundant. Every other filter,locationsincluded, works as it does on the main feed. created_atreflects the exact ingestion time on this endpoint. On the main feed,created_atis the time the job first became searchable, which can lag ingestion by however long the job spent in Jobo’s pipeline.- Requires a customer API key. Sandbox and marketplace keys receive
403.
Building this with an AI coding assistant?
Copy-paste prompts that hand your assistant this entire contract — build the sync, audit an existing one, or migrate off the client library.

