Skip to main content

Documentation Index

Fetch the complete documentation index at: https://jobo.world/docs/llms.txt

Use this file to discover all available pages before exploring further.

The Feed API has two endpoints designed to be used together:
  • Stream active jobsPOST /api/jobs/feed. Paginated stream of full job objects (active postings only).
  • Detect expired jobsGET /api/jobs/expired. Paginated stream of IDs that have expired in the last 7 days.
Use the first to add and update jobs in your store, the second to remove them. The Sync Workflow section below shows the recommended pattern end-to-end.
Both endpoints are included with a Feed subscription — no per-request credits needed. On pay-as-you-go plans, /feed charges 0.5 credits per delivered job; /expired is always free so callers can keep their inventory clean.

Keeping your system in sync

A typical integration runs an initial backfill, then an incremental sync on a schedule. Use id as your primary key — it’s stable across updates, so syncs are simple upserts.

1. Initial backfill

Page through the entire feed once with cursor pagination. Persist next_cursor after every successful batch so a crash resumes mid-stream rather than restarting from page 1. Stop when has_more is false.

2. Incremental sync

Run on a schedule (15–60 minutes is a healthy range — more frequent burns credits 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 posted_after = last_run_started_at - 15m (a small 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.
updated_at lets you detect re-published edits — store it and skip writes when it hasn’t changed.

3. Handling deletions

/feed only returns active jobs, so jobs that expire silently disappear. Sweep them with /expired on the same schedule as the incremental sync:
GET /api/jobs/expired?expired_since={last_run_started_at}&batch_size=10000
Page through with cursor, and mark every returned id as expired in your store.
expired_since is optional — when omitted the endpoint defaults to the last 24 hours, which suits high-frequency sync schedules. /expired enforces a maximum 7-day lookback (expired_since cannot be older than 7 days). If your sync stalls for longer than a week you must run a full re-sync against /feed and reconcile — any IDs in your store that no longer appear are expired.

4. Backoff & rate limits

  • On 503, wait the seconds named in Retry-After (currently 5) before retrying. The Typesense circuit breaker reopens within ~15 s.
  • On 400 Invalid cursor, drop the cursor and restart pagination — don’t loop on the same value.
  • Watch X-Credits-Balance to alert before you run dry.

End-to-end examples

These samples implement the full workflow against a small key-value store. Replace store with your real database (Postgres upsert, etc.).
"""Incremental sync: pages new/updated jobs since the last run, then sweeps expired IDs.
   Cursor is checkpointed so a crash resumes mid-stream."""
import json, time, datetime as dt, requests, pathlib

API_KEY = "YOUR_API_KEY"
BASE = "https://connect.jobo.world/api/jobs"
STATE = pathlib.Path("sync_state.json")
OVERLAP = dt.timedelta(minutes=15)

state = json.loads(STATE.read_text()) if STATE.exists() else {"last_run_at": None, "cursor": None}
this_run_at = dt.datetime.now(dt.timezone.utc).isoformat()
store = {}  # ← your DB. key = job["id"]

def post(body):
    while True:
        r = requests.post(f"{BASE}/feed",
                          headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
                          json=body)
        if r.status_code == 503:
            time.sleep(int(r.headers.get("Retry-After", "5"))); continue
        r.raise_for_status(); return r.json()

# 1. Page the feed with posted_after = last_run - overlap
posted_after = None
if state["last_run_at"]:
    posted_after = (dt.datetime.fromisoformat(state["last_run_at"]) - OVERLAP).isoformat()

cursor = state.get("cursor")
while True:
    body = {"batch_size": 1000}
    if posted_after: body["posted_after"] = posted_after
    if cursor:       body["cursor"] = cursor
    data = post(body)

    for job in data["jobs"]:
        store[job["id"]] = job  # ← upsert in your DB

    cursor = data["next_cursor"]
    STATE.write_text(json.dumps({**state, "cursor": cursor}))  # checkpoint
    if not data["has_more"]: break

# 2. Sweep expired IDs since the last successful run
if state["last_run_at"]:
    cursor = None
    while True:
        params = {"expired_since": state["last_run_at"], "batch_size": 10000}
        if cursor: params["cursor"] = cursor
        r = requests.get(f"{BASE}/expired",
                         headers={"X-Api-Key": API_KEY}, params=params).json()
        for jid in r["job_ids"]:
            store.pop(jid, None)  # ← mark expired in your DB
        cursor = r["next_cursor"]
        if not r["has_more"]: break

# 3. Commit the new checkpoint only after both halves succeeded.
STATE.write_text(json.dumps({"last_run_at": this_run_at, "cursor": None}))
print(f"Synced. {len(store)} active jobs in store.")

Endpoints

Full request/response reference and a live “Try it” playground live on the dedicated pages below.

Stream active jobs

POST /api/jobs/feed — cursor-paginated stream of full job objects (active postings only).

Detect expired jobs

GET /api/jobs/expired — paginated stream of IDs that have expired in the last 7 days.

JobDto schema

Both feed endpoints (and the search endpoints) return job objects in this shape:
FieldTypeDescription
idstring (uuid)Stable Jobo job identifier — use as the primary key when upserting.
titlestringOriginal job title as published by the employer.
normalized_titlestring|nullNormalized canonical title (snake_case, e.g. "software_engineer").
companyobjectEmbedded company summary. See Company below.
descriptionstringFull job description. HTML is stripped but line breaks are preserved.
summarystring|nullShort AI-generated summary (2–3 sentences).
listing_urlstringCanonical URL to view the job on the employer’s careers site.
apply_urlstringDirect application URL (may equal listing_url).
locationsobject[]All resolved locations for this posting. See Location.
compensationobject|nullNormalized compensation range, or null if undisclosed. See Compensation.
employment_typestring|nullOne of "full_time", "part_time", "contract", "internship", "temporary".
workplace_typestring|nullOne of "remote", "hybrid", "onsite".
experience_levelstring|nullOne of "entry", "mid", "senior", "lead", "executive".
sourcestringATS / job board source (e.g. "greenhouse", "lever", "workday").
created_atstring (datetime)UTC timestamp the job was first ingested into Jobo.
updated_atstring (datetime)UTC timestamp of the last change to the posting. Watch this for re-syncs.
date_postedstring|nullUTC date the employer originally posted the job, when known.
valid_throughstring|nullEmployer-declared expiry, when available.
qualificationsobjectStructured qualifications. See Qualifications.
responsibilitiesstring[]Bulleted responsibilities extracted from the description.
benefitsstring[]Bulleted benefits extracted from the description.
is_work_auth_requiredboolean|nullTrue if applicants must already have work authorization in the job’s country.
is_h1b_sponsorboolean|nullTrue when the hiring company is known to sponsor H-1B visas.
is_clearance_requiredboolean|nullTrue when a US security clearance is required.

Company object

company
object

Location object

location
object
An entry in the locations[] array. A single posting may cover multiple cities/countries.

Compensation object

compensation
object

Qualifications object

qualifications
object
Structured qualifications split into must-have and preferred buckets.Each bucket contains: