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:readfor the jobs themselves.events:readso 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.
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 completeFetch 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.
curl https://tahoe.workonward.com/api/partner/v1/jobs/job_3Hn6tWq9Lc2v \
-H "Authorization: Bearer $TAHOE_API_KEY"{
"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: whenfalse, 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 oftennull.slug: when there is no external URL, link to the posting Tahoe hosts, athttps://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.
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.| Event | What happens on your board |
|---|---|
job.published | The job appears. |
job.updated, job.sections_updated | The job is refreshed. |
job.closed | The job comes down, or shows as closed if your board keeps closed roles. |
job.reopened | A closed job goes live again. |
job.unpublished | The job went back to draft and comes down. If it is published again, you get job.published. |
job.deleted | The 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.closedandjob.unpublished, and treataccept_applications: falseas “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.