Skip to main content
Most integrations don’t call the Feed API once — they mirror it into a database and keep that mirror current. The pattern is always the same: an initial backfill, then a scheduled incremental sync, plus a sweep for jobs that expired. Use 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_cursor after 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_size only on the first request. The cursor embeds them; values sent alongside a cursor are ignored.
Stop when 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:
  1. Read your stored last_run_started_at. Record now as this_run_started_at before the first request.
  2. Call /feed with updated_after = last_run_started_at - 15m. The 15-minute overlap protects against clock skew and late-arriving postings.
  3. Page through with cursor until has_more is false. Upsert each job by id.
  4. After the loop succeeds, persist this_run_started_at as the new last_run_started_at.
Use updated_after, not posted_afterposted_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:
Page through with cursor, and mark every returned id as expired in your store.
expired_since cannot be older than 7 days — the endpoint rejects an older value, and when expired_since is omitted it defaults to just the last 24 hours. If your sync stalls, or simply runs less often than weekly, expired jobs silently accumulate as dead rows in your store with no way to catch them up through this endpoint. Recover by running a full backfill against /feed and reconciling: any ID in your store that no longer appears in the feed has expired.

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 503 and 429, with bounded backoff. On 503, wait the seconds named in Retry-After (currently 5) 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-Balance to alert before you run dry.
See Rate limits and Errors for the full picture.

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 locations filter is not supported on the managed endpoint — filter with sources, work_models, and posted_after instead.
  • created_at reflects the exact ingestion time on this endpoint. On the main feed, created_at reports indexing time for jobs under 24 hours old, which can lag ingestion slightly.
  • Requires a customer API key. Sandbox and marketplace keys receive 403.
See Sources for how managed job sources are added and verified.