Guides

Mirror a job board

The most common integration, and the one with the most ways to embarrass your customer. Two scopes, one backfill, then events.

What you need

jobs:read is enough for the board itself. Add events:read so you do not have to re-read the workspace on a timer.

1. Backfill

Page /jobs once with limit=100. The default status filter is already the safe one — published,closed — so you cannot accidentally publish a draft by forgetting a parameter.

Backfill
def backfill_jobs(session, store):
    cursor = None
    while True:
        params = {"status": "published", "limit": 100}
        if cursor:
            params["cursor"] = cursor
        body = session.get(f"{BASE}/jobs", params=params, timeout=30).json()

        for job in body["data"]:
            store.upsert_job(job)

        cursor = body["next_cursor"]
        # next_cursor is the ONLY correct terminator: post-query filtering can
        # return a short page that still has thousands of rows behind it.
        if not cursor:
            break

    # The last sequence at the moment the backfill finished. Anything that
    # changed DURING the backfill will arrive again as an event, which is
    # harmless because upserts are idempotent — whereas reading the watermark
    # afterwards would silently skip those changes.
    store.save_sequence(current_sequence(session))

2. Fetch content only for what you render

A list row carries summary. The full description lives on the single read, so fetch it when you render a job page — or once per job at sync time if you pre-render, but not for jobs you only list.

One job with its content
curl -s https://tahoe.workonward.com/api/partner/v1/jobs/job_7Kd2mXq4Rp8v \
  -H "Authorization: Bearer $TAHOE_API_KEY"

content.description_md is Markdown authored by your customer. Render it as Markdown, and sanitise before injecting it into a page as HTML.

3. Send applicants to the right place

Read these three fields before you build an Apply button:

  • accept_applications — when false, do not show an Apply button at all. The role is visible but closed to new applicants.
  • external_apply_url — when present, this is where applications must go. It is host-allowlisted before Tahoe returns it, so it is safe to link, but it is null more often than not.
  • slug — for building a link to the Tahoe-hosted posting when there is no external URL.

4. Stay current

Subscribe to the seven job events. Six of them are one-line handlers:

Job event handlers
JOB_EVENTS = (
    "job.published", "job.updated", "job.closed", "job.reopened",
    "job.unpublished", "job.deleted", "job.sections_updated",
)


def handle_job_event(session, store, event):
    handle = event["data"]["object"]["id"]
    kind = event["type"]

    # Two events mean "take it off the site", and they are NOT the same thing:
    # unpublished means the customer pulled a live posting (it may come back
    # via job.reopened), deleted means the row is gone. Both stop publication;
    # only one keeps the local record.
    if kind == "job.deleted":
        store.delete_job(handle)
        return
    if kind == "job.unpublished":
        store.unpublish_job(handle)
        return

    # Everything else: re-read. The event payload is thin on purpose, and
    # re-reading means what you store reflects your scopes right now.
    job = session.get(f"{BASE}/jobs/{handle}", timeout=30).json()
    store.upsert_job(job)

The four ways this goes wrong

  • Publishing a draft. Only happens if you pass include_unpublished=true. Do not, for a public board.
  • Leaving a closed role up. Handle job.closed and job.unpublished, and treat accept_applications: false as “no Apply button”.
  • Keying on the slug. Duplicates on the first retitle.
  • Polling instead of listening. Re-reading every job every fifteen minutes burns your quota to learn nothing, and still leaves you up to fifteen minutes stale.

Next

Incremental sync generalises step 4 to every resource, and deletion and erasure covers what you must do when a record goes away.