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 and
batch_sizeonly on the first request. The cursor embeds them; values sent alongside a cursor are ignored.
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.
The Python SDK does not yet expose
updated_after on feed.get_jobs / feed.iter_jobs. Build incremental sync against the raw HTTP API, as shown below.Raw HTTP
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:
- The
locationsfilter is not supported on the managed endpoint — filter withsources,work_models, andposted_afterinstead. created_atreflects the exact ingestion time on this endpoint. On the main feed,created_atreports indexing time for jobs under 24 hours old, which can lag ingestion slightly.- Requires a customer API key. Sandbox and marketplace keys receive
403.

