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.
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.
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 isnullmore 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_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.closedandjob.unpublished, and treataccept_applications: falseas “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.
