API reference

Applications

Ten endpoints. An application is the join between a job and a person, and almost everything an ATS integration needs hangs off it.

GET/applicationsapplications:read

Applications across the workspace, newest first.

ParameterTypeNotes
job_idhandleRestrict to one job. Equivalent to the nested route below.
statusstringComma-separated.
stage_idhandleApplications currently sitting in one stage.
updated_afterRFC 3339For incremental sync.
applied_afterRFC 3339Only applications submitted since.
limitintegerDefault 25, max 100.
cursorstringFrom the previous page.
Response
{
  "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": "careers_site",
      "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",
        "resume": "/api/partner/v1/applications/app_6Qm2xKd4Rp8v/resume"
      },
      "restricted": ["rejection_reason"],
      "restricted_reason": {
        "rejection_reason": "scope_required:applications:internal:read"
      }
    }
  ],
  "has_more": true,
  "next_cursor": "cur_eyJrIjoiMjAyNi0wOS0wMlQxNDoyMTowOVoi..."
}

voice_screening_opted_out is not decoration. If it is true, the candidate declined phone screening, and any workflow of yours that would call them must respect that.

GET/jobs/{job_handle}/applicationsapplications:read

The same rows, scoped to one job. Prefer this to fetching everything and filtering client-side — it is one request instead of many pages, and it keeps you well away from the paging window.

GET/applications/{application_handle}applications:read

One application. Same shape as a list row. With applications:internal:read, rejection_reason is present instead of restricted.

GET/applications/{application_handle}/answersapplications:answers:read

What the applicant typed into your customer’s application form, keyed by field id. Resolve the ids against the job’s application form to get labels and types.

Response
{
  "object": "application_answers",
  "application_id": "app_6Qm2xKd4Rp8v",
  "answers": {
    "why_this_role": "I have spent four years on ingestion pipelines and ...",
    "work_authorisation": "Yes"
  },
  "restricted": ["answers"],
  "restricted_reason": {
    "answers": "filtered:not_in_current_form — retired, consent, or equal-opportunity fields are never returned"
  }
}

GET/applications/{application_handle}/scoreapplications:read

Tahoe’s match assessment for this application against this job.

Response
{
  "object": "application_score",
  "application_id": "app_6Qm2xKd4Rp8v",
  "match_pct": 82,
  "gaps": ["No Kubernetes experience stated"],
  "restricted": ["rationale", "model_version"],
  "restricted_reason": {
    "rationale": "scope_required:applications:internal:read",
    "model_version": "scope_required:applications:internal:read"
  }
}

rationale is the model’s written reasoning about a named person, which is why it sits behind applications:internal:read rather than travelling with the number. If you surface match_pct to a recruiter, label it as Tahoe’s assessment rather than presenting it as your own.

GET/applications/{application_handle}/screeningscreening:metadata:read

Whether a phone screening happened and how it went. Metadata only — and unlike most withheld fields, no scope exists that returns the content.

Response
{
  "object": "screening",
  "application_id": "app_6Qm2xKd4Rp8v",
  "has_screening": true,
  "call_status": "completed",
  "completion_state": "all_questions_answered",
  "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 no screening has happened the response is {"has_screening": false} rather than a 404, so a nightly sync can treat “no call yet” as data instead of an error.

GET/applications/{application_handle}/stage-transitionsapplications:internal:read

Where this application has moved and when. Enough to reconstruct time-in-stage and build a funnel of your own.

Response
{
  "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
}

No actor and no free text. Which recruiter moved a candidate, and what they wrote while doing it, are not part of this response. The timeline is the fact; the commentary is not.

GET/applications/{application_handle}/resumeresume:read

Resume metadata, the parsed structured profile, and the access state that says whether you may read or download it.

Response
{
  "object": "resume",
  "id": "res_2Kd8sPm4Qx7L",
  "application_id": "app_6Qm2xKd4Rp8v",
  "filename": "priya-raman-cv.pdf",
  "mime": "application/pdf",
  "bytes": 184320,
  "parse_status": "parsed",
  "resume_access": {
    "state": "view_only",
    "free_until": "2026-09-09T14:21:09.115Z",
    "view_until": "2026-10-02T14:21:09.115Z",
    "state_changes_at": "2026-10-02T14:21:09.115Z",
    "unlock_credits": 5,
    "can_view": true,
    "can_download": false
  },
  "current_title": "Senior Data Engineer",
  "current_company": "Kakao",
  "seniority": "senior",
  "total_years_experience": 7,
  "city": "Seoul",
  "country": "KR",
  "remote_ok": true,
  "education_level": "bachelors",
  "skills": ["Python", "Airflow", "PostgreSQL"],
  "languages": ["Korean", "English"],
  "extraction_confidence": 0.94,
  "parsed_at": "2026-09-02T14:23:41.088Z",
  "structured": { "experience": [], "education": [] },
  "links": {
    "download": "/api/partner/v1/applications/app_6Qm2xKd4Rp8v/resume/download"
  }
}

GET/applications/{application_handle}/resume/downloadresume:download

A short-lived signed URL for the resume file. In the download rate-limit tier (30/minute), and every call consumes one unit of the daily personal-data budget and writes an audit row.

Request
curl -s https://tahoe.workonward.com/api/partner/v1/applications/app_6Qm2xKd4Rp8v/resume/download \
  -H "Authorization: Bearer $TAHOE_API_KEY"
Response
{
  "object": "resume_download",
  "application_id": "app_6Qm2xKd4Rp8v",
  "url": "https://tahoe.sfo3.digitaloceanspaces.com/...&X-Amz-Signature=...",
  "expires_in": 300,
  "filename": "priya-raman-cv.pdf",
  "mime": "application/pdf",
  "bytes": 184320
}

Fetch the URL within expires_in seconds and do not store it — it is a bearer capability for that one file. Store the application handle and request a fresh URL when you next need the bytes.

If the workspace has not purchased access, this returns 403 resume_locked with state and unlock_credits so you can tell a user what unlocking costs. The API mirrors the product’s paywall and is never a cheaper path to a document.

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.