Skip to main content
Mirror the job feed into your database in three parts. Use 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 to stable_scan: true: pages follow each job’s immutable creation time, so jobs updated or expired mid-backfill are never skipped or duplicated.
  • Persist next_cursor after 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 lower batch_size, for example when your included jobs or wallet balance can’t cover a full batch.
Stop when 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 use JobFeed request budget.
  1. Read your stored last_run_started_at. Before the first request, record the current time as this_run_started_at.
  2. Call /feed with updated_after = last_run_started_at - 15m. The overlap covers clock skew and late-arriving postings.
  3. Page with cursor until has_more is false, upserting each job by id.
  4. Run the expiry sweep.
  5. Only after the run succeeds, save this_run_started_at as the new last_run_started_at.
Use 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.
Page with cursor and mark every ID in job_ids as expired.
expired_since reaches back at most 7 days (older returns 400). A sync that stalls longer, or runs less than weekly, keeps dead rows this endpoint can no longer report. Recover with a full backfill (Expiry).

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 retryable 503. 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% of X-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 (with include_reactivated) and updated_after.
  • There’s no sources filter (you chose the companies), no exclude_* list, and no occupation or company filter (companies, job_categories, industries, company_sizes and 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_at is the time the job first became searchable, as on the main feed.
  • updated_after also 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 get 404 with code: 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_after and description_format. Edit the rules in the Feed Builder.
  • Every response carries feed_revision, also sent as X-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 409 with code feed_changed. Discard the cursor and start a new scan.
  • Store the feed_revision of your last full scan. When a response carries a different one, the rules changed: run a full scan instead of an incremental one.
  • updated_after also 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 get 404 with code: not_available_in_sandbox, and a feed ID from another account gets 404.

Use the same conditions on the main feed

Every condition of a feed that starts from All jobs can go on a plain POST /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_revenues and 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_after that many days before the scan starts.
  • companies.exclude takes Jobo company IDs, website domains or whole company names, as on POST /api/jobs/search. companies.include keeps 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.
Editing a saved feed’s source, conditions or excluded companies re-sends the whole feed to every outbound feed that delivers it on that outbound feed’s next sync, and removes the jobs that no longer match. The re-sent jobs are billed like any delivery, except on the Unlimited plan. Renaming the feed or changing its description re-sends nothing.
  • 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.
Setup for each destination is in the outbound feed destination guides. To query what a Postgres, Supabase or Bolt outbound feed writes, see Build a search page on your Postgres feed.

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.