API reference

Jobs

Seven endpoints. The list is thin by design and the single read is fat: mirror a board from the list, then fetch sections only for the jobs you actually render.

GET/jobsjobs:read

Jobs in the workspace, newest first. Returns published and closed jobs by default — a job board that mirrors this naively will never accidentally publish a customer’s unfinished draft.

ParameterTypeNotes
statusstringComma-separated. Defaults to published,closed. Anything else needs include_unpublished=true.
include_unpublishedbooleanOpt in to drafts, archived and other non-public states. Without it, asking for them is 400 unpublished_requires_opt_in.
departmentstringExact match.
location_typestringe.g. remote, hybrid, onsite.
employment_typestringe.g. full_time, contract.
qstringFull-text search over the job.
updated_afterRFC 3339For incremental sync. This is the parameter to reach for instead of deep paging.
published_afterRFC 3339Only jobs published since.
limitintegerDefault 25, max 100.
cursorstringFrom the previous page. Send the identical filters alongside it.
Request
curl -s "https://tahoe.workonward.com/api/partner/v1/jobs?status=published&limit=100" \
  -H "Authorization: Bearer $TAHOE_API_KEY"
Response
{
  "object": "list",
  "data": [
    {
      "object": "job",
      "id": "job_7Kd2mXq4Rp8v",
      "workspace_id": "wsp_4Kd8sPm2Qx7L",
      "slug": "senior-backend-engineer-a41f",
      "status": "published",
      "title": "Senior Backend Engineer",
      "department": "Engineering",
      "team": "Platform",
      "employment_type": "full_time",
      "location_type": "hybrid",
      "locations": ["Seoul, KR"],
      "experience_level": "senior",
      "years_min": 5,
      "years_max": 9,
      "compensation": {
        "salary_min": 90000000,
        "salary_max": 130000000,
        "currency": "KRW",
        "interval": "year",
        "unit": "major",
        "equity": null,
        "commission": null
      },
      "accept_applications": true,
      "external_apply_url": null,
      "source": {
        "system": "tahoe_native",
        "company_name": null,
        "company_logo_url": null,
        "canonical_url": null
      },
      "summary": "Own the ingestion pipeline behind Tahoe's candidate graph.",
      "published_at": "2026-08-14T09:02:11.004Z",
      "close_at": null,
      "created_at": "2026-08-12T15:41:07.882Z",
      "updated_at": "2026-09-08T11:20:45.331Z",
      "application_count": 148,
      "links": {
        "self": "/api/partner/v1/jobs/job_7Kd2mXq4Rp8v",
        "sections": "/api/partner/v1/jobs/job_7Kd2mXq4Rp8v/sections"
      }
    }
  ],
  "has_more": true,
  "next_cursor": "cur_eyJrIjoiMjAyNi0wOC0xNFQwOTowMjoxMVoi..."
}

The list is thin, the single read is fat

A list row carries summary. The full content object — description markdown, responsibilities, requirements, skills, benefits — appears only on a single-job read. Paging 100 full descriptions to render a board that shows titles would be pure waste for both sides.

GET/jobs/{job_handle}jobs:read

One job, with content instead of summary:

Response (content only)
{
  "object": "job",
  "id": "job_7Kd2mXq4Rp8v",
  "content": {
    "summary": "Own the ingestion pipeline behind Tahoe's candidate graph.",
    "description_md": "## About the role\n\nYou will own ...",
    "responsibilities": ["Design and run the ingestion pipeline", "..."],
    "requirements": ["5+ years in Python", "..."],
    "nice_to_have": ["Experience with pgvector"],
    "skills_required": ["Python", "PostgreSQL", "asyncio"],
    "skills_preferred": ["Kubernetes"],
    "benefits": ["Private health cover", "..."]
  },
  "restricted": ["voice_screening.dial_in_code"],
  "restricted_reason": {
    "voice_screening.dial_in_code": "scope_required:jobs:screening:read"
  }
}

description_md is Markdown. Render it as Markdown or strip it — do not inject it into a page as HTML without sanitising, because it is customer-authored text.

GET/jobs/by-slug/{slug}jobs:read

Resolve a public job slug to a job. Use this when you hold a URL a candidate clicked — from a job board, an email, or a referral link — and need the handle behind it, rather than storing a slug as a foreign key. Slugs are public and can change; handles cannot.

GET/jobs/{job_handle}/sectionsjobs:read

The full structured job in one call — the same content object as the single read, for callers that want the body without re-reading the metadata they already have.

GET/jobs/{job_handle}/pipeline-stagesjobs:read

The stages this job’s pipeline is made of, in order. You need these to make sense of an application’s stage_id — read them once per job and cache them, then resolve a stage handle locally.

Response
{
  "object": "list",
  "data": [
    {
      "object": "pipeline_stage",
      "id": "stg_3Rp8vKd2mXq4",
      "job_id": "job_7Kd2mXq4Rp8v",
      "name": "Phone screen",
      "position": 2,
      "type": "screening"
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Stages are per job, not per workspace: two jobs can both have a stage called “Phone screen” with different handles. Key your local copy on the stage handle, not the name.

GET/jobs/{job_handle}/application-formjobs:read

The schema of the form applicants filled in. This is what makes form answers interpretable: an answer carries a field id, and this is where the label, type and options for that id live.

Response
{
  "object": "application_form",
  "job_id": "job_7Kd2mXq4Rp8v",
  "is_template": false,
  "fields": [
    {
      "id": "why_this_role",
      "label": "Why are you interested in this role?",
      "type": "long_text",
      "required": true,
      "section": "questions",
      "help_text": "A few sentences is plenty.",
      "options": [],
      "max_length": 2000
    },
    {
      "id": "work_authorisation",
      "label": "Are you authorised to work in South Korea?",
      "type": "single_select",
      "required": true,
      "section": "eligibility",
      "help_text": null,
      "options": ["Yes", "No", "Require sponsorship"],
      "max_length": null
    }
  ]
}

Retired fields are filtered out, so a form you read today describes the form as it stands. Answers from an older submission can therefore reference a field id that is no longer listed — keep the label you saw at the time rather than resolving ids lazily.

GET/jobs/{job_handle}/prescreen-questionsjobs:screening:read

The questions a phone screening asks, grouped into common (seeded from a template and asked on every posting) and adaptive (generated from this job’s own description, capped at five).

Response
{
  "object": "prescreen_questions",
  "job_id": "job_7Kd2mXq4Rp8v",
  "common": [
    {
      "id": "f19c...",
      "position": 1,
      "prompt": "Tell me about your experience with distributed systems.",
      "response_key": "distributed_systems",
      "answer_type": "free_text",
      "choices": null,
      "required": true,
      "source": "template"
    }
  ],
  "adaptive": [
    {
      "id": "8b2a...",
      "position": 1,
      "prompt": "Have you worked with pgvector or another vector index in production?",
      "response_key": "vector_index_experience",
      "answer_type": "free_text",
      "choices": null,
      "required": false,
      "source": "generated"
    }
  ]
}

Related events

Rather than re-reading /jobs on a timer, subscribe to these on the change feed: job.published, job.updated, job.closed, job.reopened, job.unpublished, job.deleted and job.sections_updated. All seven need only jobs:read.