Skip to content

Mirror a job board

Show your Tahoe jobs on your own site and keep them current.

A job board mirror shows a customer’s live Tahoe jobs somewhere else, such as their careers site, a job board or an intranet, and keeps that copy current. It is the most common integration, and the one where a mistake is most visible: a draft that goes public, or a filled role that stays up. The recipe is two scopes, one backfill, then events.

What you need

  • jobs:read for the jobs themselves.
  • events:read so you hear about changes instead of re-reading every job on a timer.

With only these two scopes, the change feed holds job events and erasure notices, nothing else. See API keys and scopes to get a key.

Build the mirror

Take a watermark, then backfill

Page through GET /jobs once with limit=100. Ask for status=published, since a board shows only live jobs. The default filter is published,closed, and drafts are never returned unless you pass include_unpublished=true, so you cannot publish a draft by forgetting a parameter.

Cold start
import os

import requests

BASE = "https://tahoe.workonward.com/api/partner/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['TAHOE_API_KEY']}"


def current_sequence():
    """The sequence of the newest event this key can see, or None if none."""
    after, last = None, None
    while True:
        params = {"limit": 100}
        if after is not None:
            params["after"] = after
        response = session.get(f"{BASE}/events", params=params, timeout=30)
        response.raise_for_status()
        body = response.json()
        if body["data"]:
            last = body["data"][-1]["sequence"]
        if not body["has_more"]:
            return last
        after = body["next_after"]


def backfill_jobs(store):
    cursor = None
    while True:
        # Send the same filters on every page: a cursor is tied to them.
        params = {"status": "published", "limit": 100}
        if cursor:
            params["cursor"] = cursor
        response = session.get(f"{BASE}/jobs", params=params, timeout=30)
        response.raise_for_status()
        body = response.json()

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

        # next_cursor is the only correct way to stop. A short page can still
        # have more rows behind it.
        cursor = body["next_cursor"]
        if not cursor:
            return


def cold_start(store):
    watermark = current_sequence()  # 1. BEFORE the backfill
    backfill_jobs(store)            # 2. copy every published job
    store.save_sequence(watermark)  # 3. saved only once the copy is complete

Fetch content for the jobs you show

A list row carries summary but not the full description. The single read returns content, with the description, responsibilities, requirements, skills and benefits. Fetch it when you render a job page, or once per job at sync time if you pre-render. Do not fetch it for jobs you only list.

Request
curl https://tahoe.workonward.com/api/partner/v1/jobs/job_3Hn6tWq9Lc2v \
  -H "Authorization: Bearer $TAHOE_API_KEY"
Response (abridged)
{
  "object": "job",
  "id": "job_3Hn6tWq9Lc2v",
  "workspace_id": "wsp_4Kd8sPm2Qx7L",
  "slug": "field-coordinator-columbus-7f3a",
  "status": "published",
  "title": "Field Coordinator",
  "department": "Operations",
  "employment_type": "full_time",
  "location_type": "onsite",
  "locations": ["Columbus, Ohio"],
  "experience_level": "mid",
  "compensation": {
    "salary_min": 52000,
    "salary_max": 64000,
    "currency": "USD",
    "interval": "annual",
    "unit": "major",
    "equity": false,
    "commission": false
  },
  "accept_applications": true,
  "external_apply_url": null,
  "published_at": "2026-09-02T14:00:00.000Z",
  "updated_at": "2026-09-08T11:20:45.112Z",
  "content": {
    "summary": "Schedule and support field crews across central Ohio job sites.",
    "description_md": "## About the role\nYou will plan daily crew schedules ...",
    "responsibilities": ["Plan daily crew schedules", "Track equipment across sites"],
    "requirements": ["Two years coordinating field or site teams"],
    "nice_to_have": ["A valid driver's license"],
    "skills_required": ["Scheduling", "Spreadsheets"],
    "skills_preferred": [],
    "benefits": ["Health insurance", "Company vehicle"]
  },
  "links": {
    "self": "/api/partner/v1/jobs/job_3Hn6tWq9Lc2v",
    "applications": "/api/partner/v1/jobs/job_3Hn6tWq9Lc2v/applications"
  }
}

content.description_md is Markdown written by your customer. Render it as Markdown, and sanitize the HTML before you put it on a page.

Send applicants to the right place

Read three fields before you draw an Apply button:

  • accept_applications: when false, show no Apply button. The role stays visible but takes no new applicants.
  • external_apply_url: when present, applications must go there. Tahoe returns it only for HTTPS addresses on hosts it trusts, so it is safe to link, and it is often null.
  • slug: when there is no external URL, link to the posting Tahoe hosts, at https://tahoe.workonward.com/jobs/ followed by the slug.

Stay current with job events

Read the change feed from your watermark, or take webhooks, and handle the seven job events. One rule covers six of them: read the job again and store it.

Job event handler
def handle_job_event(store, event):
    if not event["type"].startswith("job."):
        return  # erasure notices name people, and a job board holds none

    handle = event["data"]["object"]["id"]
    if event["type"] == "job.deleted":
        store.delete_job(handle)
        return

    # Every other job event: read the job again. The payload is thin on
    # purpose, and a fresh read is always the current state.
    response = session.get(f"{BASE}/jobs/{handle}", timeout=30)
    if response.status_code == 404:
        store.delete_job(handle)  # deleted after the event was written
        return
    response.raise_for_status()
    store.upsert_job(response.json())

    # The board shows a job only while its stored status is "published".
    # job.closed, job.unpublished and job.reopened all come down to that.
EventWhat happens on your board
job.publishedThe job appears.
job.updated, job.sections_updatedThe job is refreshed.
job.closedThe job comes down, or shows as closed if your board keeps closed roles.
job.reopenedA closed job goes live again.
job.unpublishedThe job went back to draft and comes down. If it is published again, you get job.published.
job.deletedThe job is deleted from your table.

Four ways this goes wrong

  • Publishing a draft. This only happens if you pass include_unpublished=true. Never do that 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. You get duplicates on the first retitle.
  • Polling instead of listening. Re-reading every job every fifteen minutes uses up your rate limit to learn nothing, and still leaves you up to fifteen minutes behind. Listen to events, and if you want a backstop, check GET /jobs?updated_after= once a day.

Next steps