id as the primary key: it never changes when a job is updated, so every write is a plain upsert.
To have Jobo write the jobs for you, use an outbound feed instead.
Initial backfill
Page through the whole feed once. Scans default tostable_scan: true: pages follow each job’s immutable creation time, so jobs updated or expired mid-backfill are never skipped or duplicated.
- Persist
next_cursorafter every successful batch, so a crash resumes mid-stream. - Keep one request in flight per cursor. Cursors don’t support parallel fan-out.
- Send filters on the first request only. The cursor carries the filters, sort and
batch_size, and filters sent with a cursor are ignored. - To change filters or raise
batch_size, start a new scan. A continuation may lowerbatch_size, for example when your included jobs or wallet balance can’t cover a full batch.
has_more is false. Pagination has the full cursor rules.
The SDK’s
iter_jobs walks the cursor but can’t resume after a process restart. To resume, use the raw HTTP loop, or call client.feed.get_jobs(cursor=saved_cursor) in a loop and persist each next_cursor yourself.Incremental sync
Run the sync every 15–60 minutes. Running more often adds little: each run re-delivers the jobs updated in its 15-minute overlap, and those count as delivered jobs again. Empty pages are free but still useJobFeed request budget.
- Read your stored
last_run_started_at. Before the first request, record the current time asthis_run_started_at. - Call
/feedwithupdated_after = last_run_started_at - 15m. The overlap covers clock skew and late-arriving postings. - Page with
cursoruntilhas_moreisfalse, upserting each job byid. - Run the expiry sweep.
- Only after the run succeeds, save
this_run_started_atas the newlast_run_started_at.
updated_after as the watermark, not posted_after:
To save writes, skip a job whose
updated_at matches the value you stored.
feed.iter_jobs takes updated_after from client 4.0.0; on 3.x and earlier, use the raw HTTP loop or upgrade. After a crash, the SDK loop restarts its scan (fine for a 15-minute delta), while the raw HTTP loop resumes from its saved cursor.Handling deletions
/feed returns only active jobs: an expired job stops appearing, and nothing reports the removal. Sweep removals with GET /api/jobs/expired on the same schedule as the sync. The sweep is free but uses JobFeed request budget.
cursor and mark every ID in job_ids as expired.
Backoff and rate limits
Set a client response timeout of at least 120 seconds. The feed can spend up to 90 seconds on a request before returning a retryable503. That 503 currently sends Retry-After: 5, and the feed usually accepts requests again within about 30 seconds.
Retry only 429, 500 and 503, as the post() helpers above do. Retry strategy has the backoff rules. These responses need a restart or a top-up instead:
Watch two headers on every response:
X-RateLimit-Remaining: slow down when it drops below about 15% ofX-RateLimit-Limit. See Rate limits.X-Credits-Balance: alert before the wallet runs out.
Managed job sources
POST /api/jobs/feed/managed streams only your managed job sources: the companies you added under Managed Services → Job Scraping in the dashboard. Add a catalog company by typing its name or domain into Quick add. For companies not in the catalog, or a whole list (Upload list), the page drafts an email to support.
It shares the main feed’s batch shape, cursor rules and metering. The differences:
- Its filters are
work_models,employment_types,experience_levels,languages,locations,posted_after(withinclude_reactivated) andupdated_after. - There’s no
sourcesfilter (you chose the companies), noexclude_*list, and no occupation or company filter (companies,job_categories,industries,company_sizesand the rest). To filter on those, save a feed that starts from your Managed feed. - Jobs come oldest first, as on the main feed with
stable_scan.created_atis the time the job first became searchable, as on the main feed. updated_afteralso returns every job of a company you added on or after that time. An incremental sync picks up a new company’s existing jobs without a full rescan.- It needs a customer API key. Master and marketplace keys get
403; sandbox keys get404withcode: not_available_in_sandbox.
Saved feeds
POST /api/jobs/feed/{feed_id} streams a feed you built under Developers → Feed Builder. A feed starts from All jobs or your Managed feed (managed job sources) and narrows it with these conditions:
Excluded companies never appear in the feed, whatever the conditions say. Job category, job subcategory and occupation are the levels of each job’s
occupation. The Occupation condition is stored by occupation ID, so a renamed occupation never changes the feed.
Batch shape, order, cursor rules and metering match the main feed with stable_scan. The differences:
- The request body takes only
cursor,batch_size,updated_afteranddescription_format. Edit the rules in the Feed Builder. - Every response carries
feed_revision, also sent asX-Feed-Revision. It changes when the feed’s source, conditions or excluded companies change, not when you rename it or edit its description. - A rule change mid-scan makes the next page return
409with codefeed_changed. Discard the cursor and start a new scan. - Store the
feed_revisionof your last full scan. When a response carries a different one, the rules changed: run a full scan instead of an incremental one. updated_afteralso returns every job of a company that joined the feed on or after that time, as on the managed feed.- It needs a customer API key of the account that owns the feed. Master and marketplace keys get
403, sandbox keys get404withcode: not_available_in_sandbox, and a feed ID from another account gets404.
Use the same conditions on the main feed
Every condition of a feed that starts from All jobs can go on a plainPOST /api/jobs/feed request, using the fields in the table above. The public Feed Builder page writes that request body for you.
industries,company_primary_industries,company_categories,company_sizes,company_revenuesand the occupation filters take{"include": [...], "exclude": [...]}.- A size or revenue “at least” / “at most” condition is the list of bands it covers.
- An
exclude_*list keeps jobs that have no value for that field. - For Posted within, send the
posted_afterthat many days before the scan starts. companies.excludetakes Jobo company IDs, website domains or whole company names, as onPOST /api/jobs/search.companies.includekeeps only the listed companies.
companies entries are matched on the first page, and the cursor keeps the matched company IDs, so a scan’s companies are fixed when it starts. An entry that matches no company doesn’t fail the request. The first page lists it in warnings, with the codes repeated on X-Jobo-Warnings:
When no
include entry matches, or every match is outside sources, the first page is empty, with has_more: false, and is not billed.
To stream a feed that starts from your Managed feed, use POST /api/jobs/feed/{feed_id}. POST /api/jobs/feed/managed takes only the filters listed under Managed job sources.
Deliver a saved feed without writing the loop
An outbound feed, set up under Data Platform → Outbound Feed, runs this sync for you. It writes the jobs into Postgres, Supabase, Bolt, MongoDB, Elasticsearch, Algolia, Meilisearch or a webhook. Under Jobs to deliver, choose All jobs or one of your saved feeds. The outbound feed has no filters of its own, so build them in the Feed Builder.- A saved feed in use can’t be deleted. The Feed Builder names the outbound feeds that deliver it; choose another source in their settings first.
- If the feed becomes unavailable anyway, the outbound feed pauses as Source unavailable and keeps what it already delivered. Choose another source, then resume it.
- Active outbound feeds set up with their own filters are moved to saved feeds in your Feed Builder with the same rules, without re-sending anything.
- Any other outbound feed keeps its own filters until you choose a saved feed. They still narrow what it delivers, and its settings show them read-only under Data included.
Building this with an AI coding assistant?
Copy-paste prompts that give your assistant this contract: build the sync, audit an existing one, or migrate off the client library.

