Applications
Applications, answers, match scores, screening status and resumes, and moving, rejecting and annotating them.
An application joins one job and one person, and most of what an ATS integration needs hangs off it: the stage, the form answers, Tahoe’s match score, the phone pre-screen status and the resume. Nine endpoints read them. Five more change an application: move it to another stage, reject it, reopen it, add a note and add a scorecard. Those are below. To add a candidate to a job, see Create an application.
GET/applicationsapplications:read
Applications across the workspace, most recently updated first.
| Parameter | Type | Notes |
|---|---|---|
job_id | handle | Only applications to one job. |
applicant_id | handle | Only applications from one applicant. |
status | string | Comma-separated: new, in_review, advanced, rejected, withdrawn, hired. |
stage_id | handle | Only applications sitting in one pipeline stage now. |
applied_after | timestamp | Only applications submitted since. |
updated_after | timestamp | Only applications changed since. Use it for incremental sync. |
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/applications?job_id=job_7Kd2mXq4Rp8v&updated_after=2026-09-01T00:00:00Z" \
-H "Authorization: Bearer $TAHOE_API_KEY"{
"object": "list",
"data": [
{
"object": "application",
"id": "app_6Qm2xKd4Rp8v",
"workspace_id": "wsp_4Kd8sPm2Qx7L",
"job_id": "job_7Kd2mXq4Rp8v",
"applicant_id": "apl_5Nx3jLm7Qd2s",
"stage_id": "stg_3Rp8vKd2mXq4",
"status": "in_review",
"source": "public_board",
"applied_at": "2026-09-02T14:21:09.115Z",
"updated_at": "2026-09-07T09:44:31.002Z",
"parse_status": "parsed",
"has_resume": true,
"voice_screening_opted_out": false,
"links": {
"self": "/api/partner/v1/applications/app_6Qm2xKd4Rp8v",
"answers": "/api/partner/v1/applications/app_6Qm2xKd4Rp8v/answers",
"score": "/api/partner/v1/applications/app_6Qm2xKd4Rp8v/score",
"resume": "/api/partner/v1/applications/app_6Qm2xKd4Rp8v/resume",
"screening": "/api/partner/v1/applications/app_6Qm2xKd4Rp8v/screening",
"stage_transitions": "/api/partner/v1/applications/app_6Qm2xKd4Rp8v/stage-transitions"
},
"restricted": ["rejection_reason"],
"restricted_reason": {
"rejection_reason": "scope_required:applications:internal:read"
}
}
],
"has_more": true,
"next_cursor": "cur_eyJrIjoiMjAyNi0wOS0wN1QwOTo0NDozMVoi..."
}applicant_idcan benull. Handle it rather than assuming the link to an applicant always exists.parse_statustracks reading the resume:pending,parsing,parsedorfailed.voice_screening_opted_outistruewhen the candidate declined the phone pre-screen. Any workflow of yours that would contact them about a phone screen must respect it.rejection_reasonneedsapplications:internal:read. Without it, the field is named inrestricted.
GET/jobs/{job_handle}/applicationsapplications:read
The same rows for one job. It takes status, stage_id, applied_after, limit and cursor. Prefer it to reading everything and filtering on your side.
GET/applications/{application_handle}applications:read
One application, in the same shape as a list row. When the application has a resume, the single read also carries resume_access, explained below.
{
"object": "application",
"id": "app_6Qm2xKd4Rp8v",
"status": "in_review",
"applied_at": "2026-09-02T14:21:09.115Z",
"has_resume": true,
"resume_access": {
"state": "open",
"free_until": "2026-12-01T14:21:09.115Z",
"view_until": "2026-12-31T14:21:09.115Z",
"state_changes_at": "2026-12-01T14:21:09.115Z",
"unlock_credits": 50,
"can_view": true,
"can_download": true
},
"restricted": ["rejection_reason"],
"restricted_reason": {
"rejection_reason": "scope_required:applications:internal:read"
}
}GET/applications/{application_handle}/answersapplications:answers:read
What the applicant typed into the application form, keyed by field id. Look the IDs up in the job’s application form to get labels and types.
{
"object": "application_answers",
"application_id": "app_6Qm2xKd4Rp8v",
"answers": {
"why_this_role": "I have run night shifts for three years and want to lead a larger team.",
"shift_preference": ["Evening", "Night"]
}
}GET/applications/{application_handle}/scoreapplications:read
Tahoe’s AI match score for this application against this job: match_pct from 0 to 100, and the gaps it found. Returns 404 while the application has not been scored yet.
{
"object": "application_score",
"application_id": "app_6Qm2xKd4Rp8v",
"match_pct": 82,
"gaps": ["No forklift certification stated"],
"restricted": ["rationale", "model_version"],
"restricted_reason": {
"rationale": "scope_required:applications:internal:read",
"model_version": "scope_required:applications:internal:read"
}
}The written reasoning behind the score is about a named person, so rationale and model_version need applications:internal:read. If you show match_pct to a recruiter, label it as Tahoe’s assessment.
GET/applications/{application_handle}/screeningscreening:metadata:read
Whether the candidate took the phone pre-screen and how it went: the call status, how much of it they completed, how long it lasted and whether they consented to it. Only these facts are returned. No scope returns the recording, the transcript or the answers.
{
"object": "screening",
"application_id": "app_6Qm2xKd4Rp8v",
"has_screening": true,
"call_status": "completed",
"completion_state": "complete",
"duration_sec": 412,
"consent_state": "granted",
"ambiguous_caller": false,
"started_at": "2026-09-04T11:02:18.440Z",
"ended_at": "2026-09-04T11:09:10.771Z",
"restricted": ["responses", "transcript", "audio"],
"restricted_reason": {
"responses": "never_exposed:consent_scope",
"transcript": "never_exposed:consent_scope",
"audio": "never_exposed:consent_scope"
}
}When there has been no pre-screen, the response is a normal 200 with has_screening: false, not a 404, so a sync can treat “no call yet” as data.
{
"object": "screening",
"application_id": "app_6Qm2xKd4Rp8v",
"has_screening": false
}GET/applications/{application_handle}/stage-transitionsapplications:internal:read
Each move between pipeline stages and when it happened, oldest first. That is enough to rebuild time in stage or a funnel of your own. Resolve the stage handles with the job’s pipeline stages.
{
"object": "list",
"data": [
{
"object": "stage_transition",
"from_stage_id": null,
"to_stage_id": "stg_9Kd2mXq4Rp8v",
"at": "2026-09-02T14:21:09.115Z"
},
{
"object": "stage_transition",
"from_stage_id": "stg_9Kd2mXq4Rp8v",
"to_stage_id": "stg_3Rp8vKd2mXq4",
"at": "2026-09-05T16:38:52.309Z"
}
],
"has_more": false,
"next_cursor": null
}The response has no actor and no free text: who moved the candidate, and any note they wrote, are not included.
Resumes and the resume window
Tahoe gives free access to an applicant’s resume for a limited time after they apply, and the API follows exactly the same rules as the dashboard. It is never a cheaper way to a document.
| State | When | Parsed profile | File download |
|---|---|---|---|
open | The first 90 days after the application | Yes | Yes |
view_only | Day 90 to day 120 | Yes | No |
locked | After day 120 | No | No |
unlocked | After the workspace unlocks it, at any time | Yes | Yes |
Unlocking costs 50 credits, once, and is permanent. It happens in Tahoe, not through the API. unlock_credits in every resume_access block tells you the price, so you can tell a user what unlocking costs.
The resume_access block
| Field | Meaning |
|---|---|
state | One of the four states above. |
free_until | When the free window ends (90 days after the application). |
view_until | When view-only access ends (120 days after the application). |
state_changes_at | When the state next changes on its own, or null if it will not. |
unlock_credits | What unlocking costs: 50 credits. |
can_view, can_download | Whether the parsed profile and the file can be read right now. |
GET/applications/{application_handle}/resumeresume:read
The resume’s file details, its resume_access block, and the profile Tahoe parsed from it. Returns 404 when the application has no resume.
curl https://tahoe.workonward.com/api/partner/v1/applications/app_8Lp3wNc5Tz1k/resume \
-H "Authorization: Bearer $TAHOE_API_KEY"{
"object": "resume",
"id": "res_2Kd8sPm4Qx7L",
"application_id": "app_8Lp3wNc5Tz1k",
"filename": "dana-whitfield-resume.pdf",
"mime": "application/pdf",
"bytes": 184320,
"parse_status": "parsed",
"resume_access": {
"state": "view_only",
"free_until": "2026-08-30T14:21:09.115Z",
"view_until": "2026-09-29T14:21:09.115Z",
"state_changes_at": "2026-09-29T14:21:09.115Z",
"unlock_credits": 50,
"can_view": true,
"can_download": false
},
"links": {
"download": "/api/partner/v1/applications/app_8Lp3wNc5Tz1k/resume/download"
},
"parsed": {
"current_title": "Shift Supervisor",
"current_company": "Northwind Logistics",
"seniority": "senior",
"total_years_experience": 7,
"city": "Columbus",
"country": "US",
"remote_ok": false,
"education_level": "bachelors",
"skills": ["Team leadership", "Inventory control", "Workplace safety"],
"languages": ["English", "Spanish"],
"extraction_confidence": 0.94,
"parsed_at": "2026-06-01T14:23:41.088Z",
"structured": { "experience": [], "education": [] },
"work_authorization": null,
"visa_required": null
},
"restricted": ["parsed.raw_text"],
"restricted_reason": {
"parsed.raw_text": "scope_required:resume:raw_text:read"
}
}structuredholds the experience and education Tahoe read from the document, as free text. Do not treat its dates as normalized.- The full text of the resume,
parsed.raw_text, needsresume:raw_text:read. It counts against the daily personal-data budget. - When the state is
locked, this endpoint returns403 resume_lockedinstead of a profile. The error carries thestateandunlock_credits.
{
"detail": {
"code": "resume_locked",
"type": "payment_required",
"message": "This resume is not currently viewable by the workspace that owns it.",
"state": "locked",
"unlock_credits": 50
}
}GET/applications/{application_handle}/resume/downloadresume:download
A short-lived signed link to the resume file. It is in the download rate-limit tier (30 per minute), and each call counts one value against the daily personal-data budget and is recorded in the audit log. Returns 403 resume_locked unless the state is open or unlocked.
curl https://tahoe.workonward.com/api/partner/v1/applications/app_6Qm2xKd4Rp8v/resume/download \
-H "Authorization: Bearer $TAHOE_API_KEY"{
"object": "resume_download",
"application_id": "app_6Qm2xKd4Rp8v",
"url": "https://files.example.com/resumes/6f2c9a...?signature=...",
"expires_in": 300,
"filename": "jordan-rivera-resume.pdf",
"mime": "application/pdf",
"bytes": 184320
}Fetch the url within expires_in seconds (300) and do not store it: anyone holding it can download that file until it expires. Store the application handle, and ask for a new link when you next need the file.
Change an application
These five calls do what a recruiter does in the dashboard: drag a card to another stage, reject a candidate, undo a rejection, write a note and fill in a scorecard. They are all POST, and none of them deletes anything.
- API key only. A key that belongs to a Tahoe user can write. A Sign in with Tahoe token gets
403 write_requires_api_key, unless writes for connected apps are switched on. - The scope you need differs.
applications:writecovers move, reject and reopen. Notes neednotes:writeand scorecards needscorecards:write. They are paid plan scopes. - Recruiters see who did it. The change is attributed to the person who created the key, and the application’s activity gets one extra entry that marks the change as coming from the API. See Who a write is attributed to.
- Nothing is sent to the candidate. No email, text message or notification goes out on any of these calls. To write to a candidate, use POST /messages.
- Retries are safe with a key. Send an Idempotency-Key header. It is optional on these five calls.
A handle that is malformed, or that belongs to another workspace, returns 404 not_found. These calls use the normal rate-limit tier.
POST/applications/{application_handle}/moveapplications:write
Moves an application to another stage of the same job, as dragging the card does. The response is the application, exactly as GET /applications/{application_handle} returns it, with the new stage_id and a new updated_at.
| Field | Type | Notes |
|---|---|---|
stage_id | handle, required | A stage of this application’s job. Read the handles from the job’s pipeline stages. |
expected_updated_at | timestamp | Optional. See Concurrency. |
curl -X POST https://tahoe.workonward.com/api/partner/v1/applications/app_6Qm2xKd4Rp8v/move \
-H "Authorization: Bearer $TAHOE_API_KEY" \
-H "Idempotency-Key: move-6Qm2-to-screen-0001" \
-H "Content-Type: application/json" \
-d '{
"stage_id": "stg_3Rp8vKd2mXq4",
"expected_updated_at": "2026-10-08T21:15:51.327Z"
}'{
"object": "application",
"id": "app_6Qm2xKd4Rp8v",
"job_id": "job_7Kd2mXq4Rp8v",
"stage_id": "stg_3Rp8vKd2mXq4",
"status": "in_review",
"updated_at": "2026-10-08T21:15:53.378Z",
"restricted": ["rejection_reason"]
}400 invalid_stagemeans the handle is malformed, or the stage does not exist, or it belongs to another job or workspace. All three get the same answer.- Moving to the stage the application is already in returns
200, writes nothing and emits no event. So repeating a move is harmless even without an idempotency key. - Moving to a final stage such as Hired or Rejected changes the stage only, as in the dashboard. It does not change
status. Use reject to record an outcome. - The move is written to the application’s activity twice. One entry has the same kind the dashboard writes,
application_updated, with the old and new stage. The other has the kindpartner_api_stage_move. The stage history lists a move made through the API. A move made in the dashboard does not appear there. - It emits
application.stage_changed. The pipeline view and analytics refresh at once.
POST/applications/{application_handle}/rejectapplications:write
Sets status to rejected and stores an internal reason. The reason is for your team and is never shown to the candidate.
| Field | Type | Notes |
|---|---|---|
reason | string, required | 1 to 2,000 characters. |
expected_updated_at | timestamp | Optional. See Concurrency. |
curl -X POST https://tahoe.workonward.com/api/partner/v1/applications/app_6Qm2xKd4Rp8v/reject \
-H "Authorization: Bearer $TAHOE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "Not enough experience with distributed systems",
"expected_updated_at": "2026-10-08T21:15:51.327Z"
}'{
"object": "application",
"id": "app_6Qm2xKd4Rp8v",
"status": "rejected",
"rejection_reason": "Not enough experience with distributed systems",
"updated_at": "2026-10-08T21:20:07.114Z"
}- The response carries
rejection_reasononly if the key also holdsapplications:internal:read. Otherwise the field is named inrestricted, even though you just sent it. 409 invalid_application_statemeans the application is alreadyrejected, or iswithdrawnorhired. A second reject cannot overwrite the first reason. Withdrawn is the candidate’s decision and hired is a hiring decision, so neither can be rejected.- The stage does not change, as with the dashboard’s reject action. No rejection email is sent.
- The previous status is stored in the activity entry so that reopen can restore it. A second entry, kind
partner_api_reject, marks the change as coming from the API. - It emits
application.status_changed. The event never contains the reason.
POST/applications/{application_handle}/reopenapplications:write
Undoes a rejection. The body is optional: send {}, nothing, or expected_updated_at.
curl -X POST https://tahoe.workonward.com/api/partner/v1/applications/app_6Qm2xKd4Rp8v/reopen \
-H "Authorization: Bearer $TAHOE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "expected_updated_at": "2026-10-08T21:20:07.114Z" }'- The response is the application with its restored status and
rejection_reason: null(orrejection_reasonnamed inrestricted, as for reject). - The status goes back to what it was before the rejection when the rejection was made through the API. A rejection made in the dashboard does not record the earlier status, so the application returns to
in_review. - The reason is cleared from the application and stays in the activity history. The stage does not change.
409 invalid_application_statemeans the application is not rejected.- It emits
application.status_changedwith the restored status.
POST/applications/{application_handle}/notesnotes:write
Adds a note to an application. Returns 201.
| Field | Type | Notes |
|---|---|---|
body | string, required | The note, 1 to 10,000 characters. Leading and trailing whitespace is trimmed. |
curl -X POST https://tahoe.workonward.com/api/partner/v1/applications/app_6Qm2xKd4Rp8v/notes \
-H "Authorization: Bearer $TAHOE_API_KEY" \
-H "Idempotency-Key: note-6Qm2-referrer-0001" \
-H "Content-Type: application/json" \
-d '{ "body": "Spoke to the referrer. Strong recommendation." }'{
"object": "note",
"application_id": "app_6Qm2xKd4Rp8v",
"body": "Spoke to the referrer. Strong recommendation.",
"created_at": "2026-10-08T21:15:51.327Z",
"created_via": "api"
}- The note appears under the name of the person who created the key. Everyone in the workspace who can see the application sees it. The activity tab shows a separate entry,
partner_api_note_added, that holds the key ID and request ID but not the text. - The response has no
idand no author, and no endpoint reads notes back. There is nomentionsfield, so nobody is notified. - Without an
Idempotency-Key, two identical requests make two notes. It emits no event.
POST/applications/{application_handle}/scorecardsscorecards:write
Adds an interview scorecard with the same fields as the dashboard’s scorecard. Returns 201. All three fields are optional, but you must send at least one.
| Field | Type | Notes |
|---|---|---|
overall | string | strong_yes, yes, no or strong_no. |
ratings | object | Up to 30 entries. Each name is 1 to 100 characters. Each value is a number, a boolean or a string of at most 200 characters. Nested objects and lists are refused. The dashboard accepts any object here, and the API is stricter on purpose. |
comment | string | At most 10,000 characters. |
curl -X POST https://tahoe.workonward.com/api/partner/v1/applications/app_6Qm2xKd4Rp8v/scorecards \
-H "Authorization: Bearer $TAHOE_API_KEY" \
-H "Idempotency-Key: scorecard-6Qm2-onsite-0001" \
-H "Content-Type: application/json" \
-d '{
"overall": "yes",
"ratings": { "communication": 4, "system design": "strong" },
"comment": "Clear thinker, thin on scale."
}'{
"object": "scorecard",
"application_id": "app_6Qm2xKd4Rp8v",
"ratings": { "communication": 4, "system design": "strong" },
"overall": "yes",
"comment": "Clear thinker, thin on scale.",
"created_at": "2026-10-08T21:15:51.327Z",
"created_via": "api"
}The scorecard is attributed to the person who created the key, and the activity tab shows partner_api_scorecard_added next to it. It does not change the application’s status or stage, and it emits no event.
Concurrency
move, reject and reopen take an optional expected_updated_at: the updated_at you last read. It is compared to the millisecond, which is the precision the API returns. If the application changed since, the call returns 409 stale_resource and changes nothing. Read the application again and decide whether your change still applies.
{
"detail": {
"code": "stale_resource",
"type": "conflict",
"message": "The application changed after the time you sent in expected_updated_at.",
"param": null
}
}Without it, the call acts on whatever is there. Either way, two simultaneous calls on one application run one after the other. In the rare case where the row changes between the lock and the write, you get 409 application_modified_concurrently. Retry it.
Errors from these calls
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_request | The body failed validation: a missing, unknown or too long field. The errors list names each one. |
| 400 | invalid_stage | Move only. Use a stage handle from this application’s job. |
| 400 | invalid_idempotency_key | The Idempotency-Key header is not 8 to 255 allowed characters. |
| 403 | insufficient_scope | The key lacks the scope named in required_scope. |
| 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 application in this workspace. |
| 409 | invalid_application_state | Reject or reopen is not allowed from the current status. Read the application first. |
| 409 | stale_resource | expected_updated_at no longer matches. Read the application again. |
| 409 | application_modified_concurrently | The row changed during the write. Retry. |
| 409 | idempotency_key_reused | The same key was used with a different body. |
| 409 | idempotency_in_flight | The first request with this key is still running. Retry shortly. |
All codes are explained on Errors.
Related events
application.created, application.updated, application.status_changed, application.stage_changed, application.withdrawn, application.deleted and application.scored need applications:read. application.resume_parsed and resume.access_changed need resume:read. application.screening_completed needs screening:metadata:read. See the change feed for how to read them.
A change made through the API produces the same events as a change made in Tahoe. Each one carries origin, so your integration can recognize and skip the echo of its own write. See The origin of an event.