> ## 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.

# Feed API

> Stream active job postings and detect expired ones in high-volume batches. Built for keeping a downstream database in sync with Jobo.

The Feed API has two endpoints designed to be used together:

* **[Stream active jobs](/api-reference/jobs-feed/retrieve-a-bulk-feed-of-jobs-with-cursor-based-pagination)** — `POST /api/jobs/feed`. Paginated stream of full job objects (active postings only).
* **[Detect expired jobs](/api-reference/jobs-feed/retrieve-ids-of-recently-expired-jobs)** — `GET /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](#keeping-your-system-in-sync) section below shows the recommended pattern end-to-end.

<Note>
  Both endpoints are included with a Feed subscription. Without a subscription,
  `/feed` is metered by delivered jobs; `/expired` is unmetered so callers can
  keep downstream inventories clean.
</Note>

***

## 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 runs can 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 `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:

```http theme={null}
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.

<Warning>
  `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.
</Warning>

### 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.).

<CodeGroup>
  ```python Python theme={null}
  """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.")
  ```

  ```javascript Node.js theme={null}
  // Same pattern as the Python sample: page /feed with posted_after, then sweep /expired.
  import fs from "node:fs";

  const API_KEY = "YOUR_API_KEY";
  const BASE = "https://connect.jobo.world/api/jobs";
  const STATE_FILE = "sync_state.json";
  const OVERLAP_MS = 15 * 60 * 1000;

  const state = fs.existsSync(STATE_FILE)
    ? JSON.parse(fs.readFileSync(STATE_FILE, "utf8"))
    : { last_run_at: null, cursor: null };
  const thisRunAt = new Date().toISOString();
  const store = new Map(); // ← your DB, keyed by job.id

  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

  async function post(body) {
    while (true) {
      const r = await fetch(`${BASE}/feed`, {
        method: "POST",
        headers: { "X-Api-Key": API_KEY, "Content-Type": "application/json" },
        body: JSON.stringify(body),
      });
      if (r.status === 503) {
        await sleep(parseInt(r.headers.get("Retry-After") || "5", 10) * 1000);
        continue;
      }
      if (!r.ok) throw new Error(`Feed ${r.status}: ${await r.text()}`);
      return r.json();
    }
  }

  // 1. Page feed with posted_after = last_run - overlap
  const postedAfter = state.last_run_at
    ? new Date(new Date(state.last_run_at).getTime() - OVERLAP_MS).toISOString()
    : null;

  let cursor = state.cursor;
  while (true) {
    const body = { batch_size: 1000 };
    if (postedAfter) body.posted_after = postedAfter;
    if (cursor) body.cursor = cursor;
    const data = await post(body);

    for (const job of data.jobs) store.set(job.id, job); // ← upsert

    cursor = data.next_cursor;
    fs.writeFileSync(STATE_FILE, JSON.stringify({ ...state, cursor })); // checkpoint
    if (!data.has_more) break;
  }

  // 2. Sweep expired IDs since the last successful run
  if (state.last_run_at) {
    let c = null;
    do {
      const params = new URLSearchParams({
        expired_since: state.last_run_at,
        batch_size: "10000",
      });
      if (c) params.set("cursor", c);
      const r = await fetch(`${BASE}/expired?${params}`, {
        headers: { "X-Api-Key": API_KEY },
      }).then((res) => res.json());
      for (const id of r.job_ids) store.delete(id); // ← mark expired
      c = r.next_cursor;
      if (!r.has_more) break;
    } while (c);
  }

  // 3. Commit the new checkpoint only after both halves succeeded.
  fs.writeFileSync(
    STATE_FILE,
    JSON.stringify({ last_run_at: thisRunAt, cursor: null })
  );
  console.log(`Synced. ${store.size} active jobs in store.`);
  ```
</CodeGroup>

***

## Endpoints

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

<CardGroup cols={2}>
  <Card title="Stream active jobs" icon="rss" href="/api-reference/jobs-feed/retrieve-a-bulk-feed-of-jobs-with-cursor-based-pagination">
    `POST /api/jobs/feed` — cursor-paginated stream of full job objects (active postings only).
  </Card>

  <Card title="Detect expired jobs" icon="trash" href="/api-reference/jobs-feed/retrieve-ids-of-recently-expired-jobs">
    `GET /api/jobs/expired` — paginated stream of IDs that have expired in the last 7 days.
  </Card>
</CardGroup>

***

## JobDto schema

Both feed endpoints (and the search endpoints) return job objects in this shape:

| Field                   | Type                | Description                                                                                        |
| ----------------------- | ------------------- | -------------------------------------------------------------------------------------------------- |
| `id`                    | `string (uuid)`     | Stable Jobo job identifier — use as the primary key when upserting.                                |
| `title`                 | `string`            | Original job title as published by the employer.                                                   |
| `normalized_title`      | `string\|null`      | Normalized canonical title (snake\_case, e.g. `"software_engineer"`).                              |
| `company`               | `object`            | Embedded company summary. See [Company](#company-object) below.                                    |
| `description`           | `string`            | Full job description. HTML is stripped but line breaks are preserved.                              |
| `summary`               | `string\|null`      | Short AI-generated summary (2–3 sentences).                                                        |
| `listing_url`           | `string`            | Canonical URL to view the job on the employer's careers site.                                      |
| `apply_url`             | `string`            | Direct application URL (may equal `listing_url`).                                                  |
| `locations`             | `object[]`          | All resolved locations for this posting. See [Location](#location-object).                         |
| `compensation`          | `object\|null`      | Normalized compensation range, or `null` if undisclosed. See [Compensation](#compensation-object). |
| `employment_type`       | `string\|null`      | One of `"full_time"`, `"part_time"`, `"contract"`, `"internship"`, `"temporary"`.                  |
| `workplace_type`        | `string\|null`      | One of `"remote"`, `"hybrid"`, `"onsite"`.                                                         |
| `experience_level`      | `string\|null`      | One of `"entry"`, `"mid"`, `"senior"`, `"lead"`, `"executive"`.                                    |
| `source`                | `string`            | ATS / job board source (e.g. `"greenhouse"`, `"lever"`, `"workday"`).                              |
| `created_at`            | `string (datetime)` | UTC timestamp the job was first ingested into Jobo.                                                |
| `updated_at`            | `string (datetime)` | UTC timestamp of the last change to the posting. Watch this for re-syncs.                          |
| `date_posted`           | `string\|null`      | UTC date the employer originally posted the job, when known.                                       |
| `valid_through`         | `string\|null`      | Employer-declared expiry, when available.                                                          |
| `qualifications`        | `object`            | Structured qualifications. See [Qualifications](#qualifications-object).                           |
| `responsibilities`      | `string[]`          | Bulleted responsibilities extracted from the description.                                          |
| `benefits`              | `string[]`          | Bulleted benefits extracted from the description.                                                  |
| `is_work_auth_required` | `boolean\|null`     | True if applicants must already have work authorization in the job's country.                      |
| `is_h1b_sponsor`        | `boolean\|null`     | True when the hiring company is known to sponsor H-1B visas.                                       |
| `is_clearance_required` | `boolean\|null`     | True when a US security clearance is required.                                                     |

### Company object

<ResponseField name="company" type="object">
  <Expandable title="Company properties">
    <ResponseField name="id" type="string (uuid)">Stable Jobo company identifier.</ResponseField>
    <ResponseField name="name" type="string">Company display name.</ResponseField>
    <ResponseField name="website" type="string|null">Marketing website, when known.</ResponseField>
    <ResponseField name="logo_url" type="string|null">Hosted logo URL.</ResponseField>
    <ResponseField name="summary" type="string|null">One-paragraph company summary.</ResponseField>
    <ResponseField name="industries" type="string[]">1–3 industry tags describing what the company does.</ResponseField>
    <ResponseField name="linkedin_url" type="string|null">Company LinkedIn profile URL.</ResponseField>
    <ResponseField name="crunchbase_url" type="string|null">Company Crunchbase profile URL.</ResponseField>

    <ResponseField name="details_url" type="string|null">
      URL to the public company profile (`GET /api/companies/{id}`). Returns funding, leadership, ratings, press, etc.
    </ResponseField>
  </Expandable>
</ResponseField>

### Location object

<ResponseField name="location" type="object">
  An entry in the `locations[]` array. A single posting may cover multiple cities/countries.

  <Expandable title="Location properties">
    <ResponseField name="location" type="string|null">Raw location string as posted by the employer (before parsing).</ResponseField>
    <ResponseField name="city" type="string|null">Resolved city name.</ResponseField>
    <ResponseField name="region" type="string|null">Resolved region / state / province.</ResponseField>
    <ResponseField name="country" type="string|null">ISO country name or code.</ResponseField>
    <ResponseField name="latitude" type="number|null">Decimal degrees, when geocoding succeeded.</ResponseField>
    <ResponseField name="longitude" type="number|null">Decimal degrees, when geocoding succeeded.</ResponseField>
  </Expandable>
</ResponseField>

### Compensation object

<ResponseField name="compensation" type="object">
  <Expandable title="Compensation properties">
    <ResponseField name="min" type="number|null">Lower bound of the range.</ResponseField>
    <ResponseField name="max" type="number|null">Upper bound of the range.</ResponseField>
    <ResponseField name="currency" type="string|null">ISO 4217 currency code (e.g. `"USD"`, `"EUR"`).</ResponseField>
    <ResponseField name="period" type="string|null">One of `"hour"`, `"day"`, `"week"`, `"month"`, `"year"`.</ResponseField>
  </Expandable>
</ResponseField>

### Qualifications object

<ResponseField name="qualifications" type="object">
  Structured qualifications split into must-have and preferred buckets.

  <Expandable title="Qualifications properties">
    <ResponseField name="must_have" type="object">A `QualificationBucket` (see below) — what the employer lists as required.</ResponseField>
    <ResponseField name="preferred" type="object">A `QualificationBucket` (see below) — what the employer lists as nice-to-have.</ResponseField>
  </Expandable>

  Each bucket contains:

  <Expandable title="QualificationBucket properties">
    <ResponseField name="education" type="string[]">Degree or education requirements (e.g. `"BS in Computer Science"`).</ResponseField>
    <ResponseField name="certifications" type="string[]">Named certifications (e.g. `"AWS Certified Solutions Architect"`).</ResponseField>
    <ResponseField name="skills" type="object[]">Typed skills — each `{ name: string, type: "hard" | "soft" }` where `hard` is technical and `soft` is interpersonal.</ResponseField>
  </Expandable>
</ResponseField>
