Jobs
Read job postings, their sections, stages and forms, push postings from your own system, and publish, close or reopen jobs.
Seven endpoints read job postings, and five change them. The list is thin on purpose and the single read is full: mirror a job board from the list, then fetch the full content only for the jobs you actually show.
POST /jobs creates or updates a posting that comes from your own system, such as your ATS or careers site. It needs the jobs:write scope. Four lifecycle calls publish, unpublish, close and reopen any job in the workspace, including jobs your team wrote in Tahoe. They need jobs:manage.
GET/jobsjobs:read
Jobs in the workspace, most recently updated first. By default you get published and closed jobs only, so a board that mirrors this list can never show a customer’s unfinished draft by accident.
| Parameter | Type | Notes |
|---|---|---|
status | string | Comma-separated. Defaults to published,closed. Any other status needs include_unpublished=true. |
include_unpublished | boolean | Opt in to drafts and other statuses that are not public. Asking for them without it is 400 unpublished_requires_opt_in. |
department | string | Exact match. |
location_type | string | remote, hybrid or onsite. |
employment_type | string | full_time, part_time, contract, intern or temp. |
q | string | Full-text search over the job. |
updated_after | timestamp | Only jobs changed since. Use it for incremental sync instead of paging through everything. |
published_after | timestamp | Only jobs published since. |
limit | integer | Default 25, maximum 100. |
cursor | string | From the previous page. Send the same filters with it. |
curl "https://tahoe.workonward.com/api/partner/v1/jobs?status=published&limit=100" \
-H "Authorization: Bearer $TAHOE_API_KEY"{
"object": "list",
"data": [
{
"object": "job",
"id": "job_7Kd2mXq4Rp8v",
"workspace_id": "wsp_4Kd8sPm2Qx7L",
"slug": "warehouse-shift-lead-a41f9c02",
"status": "published",
"title": "Warehouse Shift Lead",
"department": "Operations",
"team": "Fulfillment",
"employment_type": "full_time",
"location_type": "onsite",
"locations": ["Columbus, Ohio"],
"experience_level": "senior",
"years_min": 4,
"years_max": 8,
"compensation": {
"salary_min": 58000,
"salary_max": 72000,
"currency": "USD",
"interval": "annual",
"unit": "major",
"equity": null,
"commission": false
},
"accept_applications": true,
"external_apply_url": null,
"source": {
"system": "tahoe_native",
"external_id": null,
"company_name": null,
"company_logo_url": null,
"canonical_url": null
},
"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",
"links": {
"self": "/api/partner/v1/jobs/job_7Kd2mXq4Rp8v",
"applications": "/api/partner/v1/jobs/job_7Kd2mXq4Rp8v/applications",
"application_form": "/api/partner/v1/jobs/job_7Kd2mXq4Rp8v/application-form",
"prescreen_questions": "/api/partner/v1/jobs/job_7Kd2mXq4Rp8v/prescreen-questions",
"pipeline_stages": "/api/partner/v1/jobs/job_7Kd2mXq4Rp8v/pipeline-stages",
"sections": "/api/partner/v1/jobs/job_7Kd2mXq4Rp8v/sections"
},
"summary": "Run the night shift for a 40-person fulfillment team."
}
],
"has_more": true,
"next_cursor": "cur_eyJrIjoiMjAyNi0wOC0xNFQwOTowMjoxMVoi..."
}A list row carries a short summary. The full content (the description, responsibilities, requirements, skills and benefits) comes only from a single-job read. Salaries are in whole units of the currency, never cents, which is what "unit": "major" says. A job created in Tahoe has source.system set to tahoe_native.
GET/jobs/{job_handle}jobs:read
One job, with the full content object in place of summary, plus application_count. The response below is shortened to the parts that differ from a list row.
curl https://tahoe.workonward.com/api/partner/v1/jobs/job_7Kd2mXq4Rp8v \
-H "Authorization: Bearer $TAHOE_API_KEY"{
"object": "job",
"id": "job_7Kd2mXq4Rp8v",
"title": "Warehouse Shift Lead",
"status": "published",
"content": {
"summary": "Run the night shift for a 40-person fulfillment team.",
"description_md": "## About the role\n\nYou will lead the night shift ...",
"responsibilities": ["Plan staffing for each shift", "Run the start-of-shift safety briefing"],
"requirements": ["4+ years in warehouse operations", "2+ years leading a team"],
"nice_to_have": ["Forklift certification"],
"skills_required": ["Team leadership", "Inventory control", "Workplace safety"],
"skills_preferred": ["Lean methods"],
"benefits": ["Health, dental and vision cover", "Paid time off"]
},
"restricted": ["voice_screening.dial_in_code"],
"restricted_reason": {
"voice_screening.dial_in_code": "scope_required:jobs:screening:read"
}
}description_md is Markdown written by your customer. Render it as Markdown or strip it, and sanitize it before you put it in a web page.
GET/jobs/by-slug/{slug}jobs:read
Turns the slug from a public job URL into the job, in the same shape as the single read. Use it when all you hold is a link a candidate clicked. Store the job_ handle it returns, not the slug.
GET/jobs/{job_handle}/sectionsjobs:read
Everything about one job in a single call: the full job, plus pipeline_stages and application_form (the same objects as the two endpoints below). With jobs:screening:read it also includes prescreen_questions; without it, prescreen_questions is named in restricted. This is the call to make when you mirror a job in full.
GET/jobs/{job_handle}/pipeline-stagesjobs:read
The stages of this job’s pipeline, in order, with how many applications sit in each now. You need these to make sense of an application’s stage_id: read them once per job, keep them, and look stage handles up locally.
{
"object": "list",
"data": [
{
"object": "pipeline_stage",
"id": "stg_9Kd2mXq4Rp8v",
"job_id": "job_7Kd2mXq4Rp8v",
"name": "Applied",
"position": 1,
"type": "applied",
"is_terminal": false,
"application_count": 70
},
{
"object": "pipeline_stage",
"id": "stg_3Rp8vKd2mXq4",
"job_id": "job_7Kd2mXq4Rp8v",
"name": "Screen",
"position": 2,
"type": "screen",
"is_terminal": false,
"application_count": 41
},
{
"object": "pipeline_stage",
"id": "stg_6Tz1wNc8Lm3q",
"job_id": "job_7Kd2mXq4Rp8v",
"name": "Hired",
"position": 5,
"type": "hired",
"is_terminal": true,
"application_count": 2
}
],
"has_more": false,
"next_cursor": null
}Stages belong to a job, not to the workspace. Two jobs can both have a stage called “Screen” with different handles, so key your copy on the handle, not the name. is_terminal marks the stages an application ends in, such as Hired and Rejected.
GET/jobs/{job_handle}/application-formjobs:read
The fields of the form applicants filled in. This is what makes form answers readable: an answer is keyed by field id, and this is where that field’s label, type and options live.
{
"object": "application_form",
"job_id": "job_7Kd2mXq4Rp8v",
"is_template": false,
"fields": [
{
"id": "name",
"label": "Name",
"type": "text",
"required": true,
"section": null,
"help_text": null,
"options": [],
"max_length": null
},
{
"id": "why_this_role",
"label": "Why are you interested in this role?",
"type": "textarea",
"required": true,
"section": null,
"help_text": "A few sentences is plenty.",
"options": [],
"max_length": 2000
},
{
"id": "shift_preference",
"label": "Which shifts can you work?",
"type": "multiselect",
"required": false,
"section": null,
"help_text": null,
"options": ["Day", "Evening", "Night"],
"max_length": null
}
]
}Fields that were removed from the form are left out, so the form you read today is the form as it stands. An older application can therefore have an answer for a field that is no longer listed. Keep the label you saw at the time rather than looking it up later.
GET/jobs/{job_handle}/prescreen-questionsjobs:screening:read
The questions the phone pre-screen asks, in two groups. common holds the standard questions asked on every posting. adaptive holds up to five questions for this particular job. source says where each question came from: template, ai (written by Tahoe from the job description) or recruiter.
{
"object": "prescreen_questions",
"job_id": "job_7Kd2mXq4Rp8v",
"common": [
{
"id": "f19c2a7e-0d4b-4e7a-9c51-3b8f6a2d1e90",
"position": 2,
"prompt": "Are you currently employed?",
"response_key": "currently_employed",
"answer_type": "boolean",
"choices": null,
"required": true,
"source": "template"
}
],
"adaptive": [
{
"id": "8b2a4c6d-1e3f-4a5b-8c7d-9e0f1a2b3c4d",
"position": 1,
"prompt": "How many people have you scheduled on a single shift?",
"response_key": "largest_shift_scheduled",
"answer_type": "number",
"choices": null,
"required": false,
"source": "ai"
}
]
}Push a job from your own system
POST/jobsjobs:write
Creates or updates one job posting from your system, and can publish it on the workspace’s Tahoe job board. There is no separate publish call: send the posting again with "publish": true. It never deletes a posting, and it never edits a posting that was created in Tahoe: it finds only the postings that were pushed through it. To publish, close or reopen a posting your team wrote in Tahoe, use the lifecycle calls.
Only an API key can write. A Sign in with Tahoe token may read, and gets 403 write_requires_api_key here unless writes for connected apps are switched on. A cross-workspace key names the workspace with ?workspace_id=, as for any other call. POST /jobs is in the expensive rate-limit tier (60 requests per minute). It accepts an optional Idempotency-Key header. Without one, its behavior is unchanged.
How a push finds its posting
A push is matched on three things: the workspace, source.system and source.external_id. Two systems in one workspace may reuse the same external_id, because the system name is part of the match. The first push for a pair creates the posting and returns 201. Every later push with the same pair updates that same posting and returns 200. Sending the same body twice leaves the posting as it was, so a push is safe to retry. Only postings created through this endpoint are matched, which is why a push can never touch a posting your customer made in Tahoe.
Request body
The body uses the same field names and nesting as a job you read, but only the fields below are accepted. Any other field, including read-only ones like id, slug or links, is a 400 invalid_request.
| Field | Type | Notes |
|---|---|---|
source.system | string, required | The name of your system, which you choose. 2 to 40 lowercase letters, digits and underscores. Names that start with tahoe, and a few that Tahoe uses itself, are reserved. |
source.external_id | string, required | Your own ID for the posting, 1 to 200 characters. |
source.canonical_url | string | The posting on your own site. Must start with https://. |
source.company_name | string | The hiring company, at most 200 characters. |
source.company_logo_url | string | Must start with https://. |
title | string, required | At most 300 characters. |
department, team | string | At most 200 characters each. |
employment_type | string | full_time, part_time, contract, intern or temp. |
location_type | string | remote, hybrid or onsite. |
locations | string[] | Up to 20. |
experience_level | string | intern, junior, mid, senior, lead or principal. |
years_min, years_max | number | 0 to 60. |
content.summary | string | At most 2,000 characters. |
content.description_md | string | Markdown, at most 30,000 characters. |
content.responsibilities, content.requirements, content.nice_to_have | string[] | Up to 50 items each. |
content.skills_required, content.skills_preferred | string[] | Up to 100 items each. |
content.benefits | string[] | Up to 50 items. |
compensation.salary_min, compensation.salary_max | integer | Whole units of the currency, 0 to 100,000,000. The maximum must be at least the minimum. |
compensation.currency | string | Three letters. Default USD. |
compensation.interval | string | hourly, monthly or annual. Default annual. |
compensation.equity | string | Free text, at most 200 characters. |
compensation.commission | boolean | Default false. |
compensation.unit | string | Optional. If you send it, it must be major. |
accept_applications | boolean | Default true. |
publish | boolean | Default false. See below. |
status | string | Only closed is accepted. See below. |
curl -X POST https://tahoe.workonward.com/api/partner/v1/jobs \
-H "Authorization: Bearer $TAHOE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": {
"system": "acme_ats",
"external_id": "REQ-2041",
"canonical_url": "https://example.com/careers/REQ-2041",
"company_name": "Kestrel Labs",
"company_logo_url": "https://files.example.com/kestrel-labs/logo.png"
},
"title": "Field Coordinator",
"department": "Operations",
"employment_type": "full_time",
"location_type": "onsite",
"locations": ["Columbus, Ohio"],
"experience_level": "mid",
"years_min": 2,
"years_max": 5,
"content": {
"summary": "Coordinate field crews and equipment across three sites in central Ohio.",
"description_md": "## About the role\n\nYou will plan daily crew schedules ...",
"responsibilities": ["Build and publish weekly crew schedules", "Track equipment across sites"],
"requirements": ["2+ years coordinating field or site teams", "A valid driver license"],
"nice_to_have": ["Experience with field service scheduling software"],
"skills_required": ["Scheduling", "Microsoft Excel"],
"skills_preferred": ["Inventory tracking"],
"benefits": ["Health, dental and vision cover", "Company vehicle"]
},
"compensation": {
"salary_min": 52000,
"salary_max": 64000,
"currency": "USD",
"interval": "annual"
},
"publish": true
}'{
"object": "job",
"id": "job_5Hw9rTc2Vn6m",
"workspace_id": "wsp_4Kd8sPm2Qx7L",
"slug": "field-coordinator-columbus-3f9a2c41",
"status": "published",
"title": "Field Coordinator",
"department": "Operations",
"team": null,
"employment_type": "full_time",
"location_type": "onsite",
"locations": ["Columbus, Ohio"],
"experience_level": "mid",
"years_min": 2,
"years_max": 5,
"compensation": {
"salary_min": 52000,
"salary_max": 64000,
"currency": "USD",
"interval": "annual",
"unit": "major",
"equity": null,
"commission": false
},
"accept_applications": true,
"external_apply_url": null,
"source": {
"system": "acme_ats",
"external_id": "REQ-2041",
"company_name": "Kestrel Labs",
"company_logo_url": "https://files.example.com/kestrel-labs/logo.png",
"canonical_url": "https://example.com/careers/REQ-2041"
},
"published_at": "2026-10-07T14:03:55.218Z",
"close_at": null,
"created_at": "2026-10-07T14:03:54.901Z",
"updated_at": "2026-10-07T14:03:55.218Z",
"application_count": 0,
"links": {
"self": "/api/partner/v1/jobs/job_5Hw9rTc2Vn6m",
"applications": "/api/partner/v1/jobs/job_5Hw9rTc2Vn6m/applications",
"application_form": "/api/partner/v1/jobs/job_5Hw9rTc2Vn6m/application-form",
"prescreen_questions": "/api/partner/v1/jobs/job_5Hw9rTc2Vn6m/prescreen-questions",
"pipeline_stages": "/api/partner/v1/jobs/job_5Hw9rTc2Vn6m/pipeline-stages",
"sections": "/api/partner/v1/jobs/job_5Hw9rTc2Vn6m/sections"
},
"content": {
"summary": "Coordinate field crews and equipment across three sites in central Ohio.",
"description_md": "## About the role\n\nYou will plan daily crew schedules ...",
"responsibilities": ["Build and publish weekly crew schedules", "Track equipment across sites"],
"requirements": ["2+ years coordinating field or site teams", "A valid driver license"],
"nice_to_have": ["Experience with field service scheduling software"],
"skills_required": ["Scheduling", "Microsoft Excel"],
"skills_preferred": ["Inventory tracking"],
"benefits": ["Health, dental and vision cover", "Company vehicle"]
},
"restricted": ["voice_screening.dial_in_code"],
"restricted_reason": {
"voice_screening.dial_in_code": "scope_required:jobs:screening:read"
}
}The response is the full job, exactly as GET /jobs/{job_handle} would return it. Store its id if you want to read the posting’s applications later. You can also find it again from source.external_id, which every read returns.
Publishing and closing
"publish": truepublishes a posting that is a draft, scheduled or closed. On a posting that is already live, it just applies your update."publish": false, or leaving it out, never takes a live posting down. A new posting pushed this way stays a draft in Tahoe and does not appear on the job board."status": "closed"closes a posting that is published or scheduled, for a role you have taken down on your own site. Send it with the rest of the posting. The posting stays in Tahoe with its applications, and its public page says the role is closed.POST /jobsnever unpublishes. To take a posting back to a draft, use POST /jobs/{job_handle}/unpublish. Nothing in the API deletes a posting.
When a push publishes, reopens or closes a posting, a job.published, job.reopened or job.closed event appears on the change feed.
What you cannot set
- Phone pre-screening. It is always off for a pushed posting, so its candidates are never asked to take a phone screen they did not expect. A recruiter can turn it on in Tahoe.
- The slug, the 6-digit Job ID and the public page’s metadata. Tahoe creates them.
- Read-only fields such as
id,workspace_id,application_count,external_apply_url,linksand the timestamps. Strip them if you build the body from a job you read. - Any status other than closed.
When a recruiter edits a pushed posting
If someone edits a pushed posting in Tahoe, the fields they changed keep their edits. Your later pushes still update every field they did not touch, so a typo fixed in Tahoe does not freeze the whole posting.
Errors from POST /jobs
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_request | The body failed validation: an unknown field, a missing title, a URL that is not https, a maximum salary below the minimum. The errors list names each field. Fix the body and resend. |
| 400 | unsupported_source_system | source.system does not match 2 to 40 lowercase letters, digits and underscores, or it is a name Tahoe reserves. Pick another name. |
| 403 | insufficient_scope | The key does not carry jobs:write. Create a new key that has it. |
| 403 | write_requires_api_key | The request used a Sign in with Tahoe token, or a key that cannot be tied to a Tahoe user. Use an API key. |
| 409 | job_modified_concurrently | The posting changed while your push was being saved, usually because a recruiter edited it at the same moment. Fetch it again and resend: a push is safe to repeat. |
| 409 | invalid_job_transition | The publish or close you asked for is not allowed from the posting’s current status. Do not resend it unchanged: read the job and check its status first. |
| 429 | rate_limit_exceeded | More than 60 expensive-tier requests in a minute. Wait for the time in Retry-After. |
Every error has the same shape. Errors lists all of them.
Publish, unpublish, close and reopen
Four calls change the status of a job, the same way the buttons in the dashboard do. They work on any job in the workspace, including a job a recruiter wrote in Tahoe. That is why they have their own scope: POST /jobs touches only the postings your own system created, and a key that may push its postings should not also be able to close the whole job board. A key that holds only jobs:write gets 403 insufficient_scope with required_scope: "jobs:manage".
| Call | Allowed from | Result | Event |
|---|---|---|---|
publish | draft, scheduled, closed | published | job.published |
unpublish | published, scheduled | draft | job.unpublished |
close | published, scheduled | closed | job.closed |
reopen | closed | published | job.reopened |
Any other combination, such as publishing a job that is already published or closing a draft, returns 409 invalid_job_transition. The message names the status and never contains job content.
POST/jobs/{job_handle}/publishjobs:manage
POST/jobs/{job_handle}/unpublishjobs:manage
POST/jobs/{job_handle}/closejobs:manage
POST/jobs/{job_handle}/reopenjobs:manage
All four take the same optional body, and all return 200 with the job, in the shape of GET /jobs/{job_handle}: application_count is included, and voice_screening.dial_in_code only if the key also holds jobs:screening:read.
| Field | Type | Notes |
|---|---|---|
expected_updated_at | timestamp | Optional. The updated_at you last read from this job, to the millisecond. If the job changed since, the call returns 409 stale_resource and changes nothing. Leave it out to act on whatever is there. Any other field is 400 invalid_request. |
curl -X POST https://tahoe.workonward.com/api/partner/v1/jobs/job_7Kd2mXq4Rp8v/publish \
-H "Authorization: Bearer $TAHOE_API_KEY" \
-H "Idempotency-Key: publish-7Kd2-0001" \
-H "Content-Type: application/json" \
-d '{ "expected_updated_at": "2026-10-08T12:00:00.123Z" }'{
"object": "job",
"id": "job_7Kd2mXq4Rp8v",
"workspace_id": "wsp_4Kd8sPm2Qx7L",
"slug": "data-engineer-remote-ab12cd",
"status": "published",
"title": "Data Engineer",
"published_at": "2026-10-08T12:00:00.123Z",
"updated_at": "2026-10-08T12:00:00.456Z",
"application_count": 0,
"content": { "summary": "...", "description_md": "..." },
"links": { "self": "/api/partner/v1/jobs/job_7Kd2mXq4Rp8v" }
}curl -X POST https://tahoe.workonward.com/api/partner/v1/jobs/job_7Kd2mXq4Rp8v/close \
-H "Authorization: Bearer $TAHOE_API_KEY" \
-H "Idempotency-Key: close-7Kd2-0001"What each call does
- Publish does what the dashboard’s publish does. It creates the public slug once and never changes it afterwards, including across an unpublish and a republish. It embeds the job description for matching, adds the common phone-screen questions, generates search metadata if none was set, and clears the public job-board cache so the change shows within seconds. Embedding, questions and metadata are best effort: if one fails, the job is still published. Adaptive pre-screen questions are generated unless a recruiter already reviewed that job’s questions, and that uses the workspace’s AI quota as the dashboard does.
- Unpublish returns the job to
draft, so the public page stops resolving. Every application is kept. - Close keeps the public page up and marks the role as closed. Every application is kept, and no applicant is rejected or messaged.
- Reopen makes a closed job published again. It is not checked for completeness, because a closed job was public already.
- Nothing is sent to any candidate or applicant by any of the four calls: no email, text message or notification.
- Each call writes the same activity entry the dashboard button writes (
job_published,job_unpublished,job_closedorjob_reopened), attributed to the person who created the key. The activity feed reads the same whoever pressed the button. publishis in theexpensiverate-limit tier, because it embeds the description. The other three use the normal tier.
Errors from the lifecycle calls
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_request | An unknown body field, or expected_updated_at is not a timestamp. |
| 400 | invalid_idempotency_key | The Idempotency-Key header is not 8 to 255 allowed characters. |
| 403 | insufficient_scope | The key lacks jobs:manage. |
| 403 | write_requires_api_key | The credential is a Sign in with Tahoe token, or is not tied to a Tahoe user. |
| 404 | not_found | No such job in this workspace. A job in another workspace and a deleted job give the same answer. |
| 409 | invalid_job_transition | The job’s current status does not allow this call. Read the job first. |
| 409 | stale_resource | expected_updated_at no longer matches. Read the job again and retry. |
| 409 | job_modified_concurrently | The job changed between the check and the write. Read it again and retry. |
| 409 | idempotency_key_reused | The same key was used with a different request. |
| 409 | idempotency_in_flight | The first request with this key is still running. Retry shortly. |
| 422 | job_not_publishable | Publish only. The job lacks what a public page needs. reasons lists the missing parts. |
| 429 | rate_limit_exceeded | Over the limit. Wait for Retry-After. |
{
"detail": {
"code": "job_not_publishable",
"type": "invalid_request",
"message": "The job is missing: description",
"reasons": ["description"]
}
}reasons holds title when the title is blank, and description when neither description_md nor summary has any text. This is the minimum a public page cannot do without. It is not a copy of a rule in the product, whose publish button has no server-side check.
Each call emits one event: job.published, job.unpublished, job.closed or job.reopened. It holds identifiers only, plus status and previous_status, and it carries origin. A refused call emits nothing. With an Idempotency-Key, a repeated request returns the first response with Idempotent-Replayed: true and does not run the transition or emit again. Without one, a repeat meets the state machine: publishing twice returns 409 invalid_job_transition the second time.
Related events
The change feed has seven job event types: job.published, job.updated, job.closed, job.reopened, job.unpublished, job.deleted and job.sections_updated. All seven need only jobs:read. Use them together with updated_after rather than re-reading every job on a timer. An event caused by your own write carries origin, so you can recognize and skip it.
To mirror a whole job board step by step, see Mirror a job board.