Skip to content

Create an application

Upload a resume and add a candidate to a job from your own system.

Two endpoints let a system you already run, such as an applicant tracking system, a sourcing tool or a careers site, push a candidate into a Tahoe job. POST /uploads reserves a place for a resume file, and POST /applications creates the application. Both need applications:write, an API key that belongs to a Tahoe user, and the expensive rate-limit tier. A Sign in with Tahoe token gets 403 write_requires_api_key, unless writes for connected apps are switched on. Both accept an optional Idempotency-Key header.

To move, reject or annotate an application afterwards, see Change an application.

The order of the calls

  1. If the candidate has a resume, call POST /uploads and PUT the file to the URL it returns.
  2. Call POST /applications with the job, the candidate and, if you uploaded a file, its upload_id as resume_upload_id.

POST/uploadsapplications:write

Reserves a short-lived URL for uploading one resume file straight to storage. Request bodies on the API are capped at 256 KB, so a resume can never travel inside POST /applications. Returns 201.

FieldTypeNotes
content_typestring, requiredOne of application/pdf, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/msword, application/rtf, text/rtf or text/plain.
size_bytesinteger, requiredThe exact length of the file, from 1 to 5,242,880 (5 MB).
filenamestringOptional. What a recruiter sees as the resume’s name. Only a plain file name whose extension matches the content type is kept. Anything else becomes resume.<ext>.
Request
curl -X POST https://tahoe.workonward.com/api/partner/v1/uploads \
  -H "Authorization: Bearer $TAHOE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "content_type": "application/pdf",
    "size_bytes": 184320,
    "filename": "ada-lovelace-cv.pdf"
  }'
Response: 201
{
  "object": "upload",
  "upload_id": "upl_3f9c2a7e1b5d4c8f9a0e6b7d2c1a8f54",
  "content_type": "application/pdf",
  "size_bytes": 184320,
  "max_bytes": 5242880,
  "upload": {
    "method": "PUT",
    "url": "https://storage.example.com/partner-uploads/...",
    "headers": {
      "Content-Type": "application/pdf",
      "Content-Length": "184320"
    },
    "expires_at": "2026-10-08T12:15:00.000Z"
  },
  "expires_at": "2026-10-08T13:00:00.000Z"
}

Send a PUT to upload.url with the file bytes as the body and exactly the headers in upload.headers. The type and the length are part of the signature, so storage refuses a file that differs from what you declared.

Uploading the file
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/pdf" \
  --data-binary @ada-lovelace-cv.pdf
  • The URL works for 15 minutes (upload.expires_at). The upload_id can be named in POST /applications for one hour (expires_at), and only once.
  • The upload URL points at storage, not at the Tahoe API. Do not send your Authorization header with the PUT.
  • A workspace may hold at most 500 uploads that were never attached and have not expired. Past that, the call returns 429 too_many_pending_uploads with Retry-After: 600.
  • With an Idempotency-Key, a repeated call returns the first response, including the same URL, with Idempotent-Replayed: true. Without one, every call reserves a new upload. It emits no event.

POST/applicationsapplications:write

Adds a candidate to a job. Returns 201 with the application when one was created, and 200 with the existing application when this candidate is already on the job.

FieldTypeNotes
job_idhandle, requiredThe job. It must be published and accepting applications.
candidateobject, requiredfirst_name and last_name (1 to 100 characters each) and email are required. phone may contain only digits, spaces and + ( ) . -. linkedin_url must be an https address on linkedin.com.
resume_upload_idstringOptional. The upload_id from POST /uploads, once the file has been uploaded.
sourceobject, requiredsystem matches ^[a-z0-9_]{2,40}$. Names that start with tahoe, and a few that Tahoe uses itself, are reserved and refused. external_id is optional, 1 to 200 characters.
answersarrayUp to 100 items of field_id and value. Each must answer a question on the job’s application form. A value is a string, a boolean, or a list of strings for a multiple-choice question. A question with options takes one of its options.
stage_idhandleOptional. Defaults to the job’s first stage. It must belong to the job and must not be a final stage such as Hired or Rejected.
Request
curl -X POST https://tahoe.workonward.com/api/partner/v1/applications \
  -H "Authorization: Bearer $TAHOE_API_KEY" \
  -H "Idempotency-Key: push-cand-991-0001" \
  -H "Content-Type: application/json" \
  -d '{
    "job_id": "job_7Kd2mXq4Rp8v",
    "candidate": {
      "first_name": "Ada",
      "last_name": "Lovelace",
      "email": "[email protected]",
      "phone": "+1 415 555 0142",
      "linkedin_url": "https://www.linkedin.com/in/ada"
    },
    "resume_upload_id": "upl_3f9c2a7e1b5d4c8f9a0e6b7d2c1a8f54",
    "source": { "system": "acme_ats", "external_id": "cand-991" },
    "answers": [
      { "field_id": "field_1696_1", "value": "I like data pipelines." },
      { "field_id": "field_1696_2", "value": ["Python", "SQL"] }
    ],
    "stage_id": "stg_3Rp8vKd2mXq4"
  }'
Response: 201
{
  "object": "application",
  "id": "app_6Qm2xKd4Rp8v",
  "workspace_id": "wsp_4Kd8sPm2Qx7L",
  "job_id": "job_7Kd2mXq4Rp8v",
  "applicant_id": "apl_5Nx3jLm7Qd2s",
  "stage_id": "stg_3Rp8vKd2mXq4",
  "status": "new",
  "source": "partner_api:acme_ats",
  "applied_at": "2026-10-08T12:00:00.000Z",
  "updated_at": "2026-10-08T12:00:00.000Z",
  "parse_status": "pending",
  "has_resume": true,
  "voice_screening_opted_out": false,
  "resume_access": { "state": "open", "...": "..." },
  "restricted": ["rejection_reason"],
  "restricted_reason": { "rejection_reason": "scope_required:applications:internal:read" },
  "links": { "self": "/api/partner/v1/applications/app_6Qm2xKd4Rp8v" }
}

The response is the object GET /applications/{application_handle} returns to a key without the sensitive scopes. It does not repeat the name, email, phone or LinkedIn address you sent. Read them back with the applicant reads, if the key has the contact scopes.

Sending the same candidate twice is safe

An existing application is found in this order, and it is returned with 200:

  1. If source.external_id is given, and an application on this job was already created with the same source.system and external_id.
  2. If the candidate’s email (not case sensitive) is already an applicant on this job, whatever the source of that application.

A repeat is answered before the request is validated, so a retry still succeeds after the job’s form has changed. A repeat changes nothing: the candidate’s details are not updated, and a resume named in the repeat is not used. These two keys protect “the ATS re-sent the whole batch”. The Idempotency-Key header protects one HTTP retry.

What this call does not do

A system pushing a person in cannot give that person’s agreements for them, so the call records none, and has no field that could.

  • No consent is recorded. There is no voice-screening consent, no user-agreement acceptance and no equal-opportunity answer. A body that contains such a field is refused as an unknown field. The application’s phone number for the screening call is left empty, so an application made this way cannot be matched to an inbound pre-screening call. The candidate can apply on the job’s public page to take part.
  • Nothing is sent to the candidate. No confirmation email, no status link, no text message and no call. The candidate does not know the application exists unless your system tells them. The job owner gets no email either.
  • Existing details are kept. If the candidate already exists in the workspace, the application is attached to that person, the details the person already has are kept and only blanks are filled from your request. The public apply form works the other way round, because there the person is the authority on their own details.
  • Only a published job takes an application. Tahoe has no recruiter-side way to add a candidate to a draft or closed job, so this call does not add one.
  • Erased and unsubscribed people are refused. The check reads the suppression list that the erasure feed publishes. If the list cannot be read, nothing is created and you get a retryable error.
409 Conflict
{
  "detail": {
    "code": "applicant_suppressed",
    "type": "conflict",
    "message": "This person has asked Tahoe to stop processing their data. No application was created, and you should stop sending this person.",
    "param": null
  }
}

What recruiters see

The application appears in the job’s pipeline in the stage you chose (the first stage by default), with status new and source partner_api:<system>. Tahoe adds the partner_api: prefix, so you send only acme_ats. The activity log has an “application received” entry attributed to the person who created the key. The resume is read by the same background worker as board applications, and recruiters read it through Tahoe under the same access rules. The workspace gets an in-app “New application received” notification.

The file is checked again when you create the application: it must exist, be non-empty and be at most 5 MB. It is then copied to the same private location the job board uses. An upload that is never attached expires after one hour.

Events

Only when an application is created, and never for a 200: application.created with source set to partner_api:<system>, and applicant.created when the candidate did not exist in the workspace before. Both carry identifiers only and an origin.

Errors

StatusCodeWhat to do
400invalid_requestMalformed body, unknown field, bad email, bad source.system, a reserved name or an over-long value.
400invalid_uploadPOST /uploads only. The file type or size is not accepted.
400invalid_stageThe stage is not on this job, is a final stage, or its handle is malformed.
400unknown_answer_fieldAn answer names a question the job’s form does not have. param is answers[i].field_id.
400duplicate_answer_fieldThe same question was answered twice.
400invalid_answer_valueThe value has the wrong shape, or is not one of the question’s options.
400answer_field_not_acceptedThe question is an equal-opportunity question, a consent box, a section header or the resume field.
400answer_field_reservedThe question is the name, email, phone or LinkedIn field. Send those in candidate.
400missing_required_answersThe form has required questions with no answer. The message names the questions, never the candidate. A required phone number and the resume are not counted.
400upload_not_foundresume_upload_id is unknown, expired, or belongs to another workspace.
400resume_rejectedThe uploaded file is empty or larger than 5 MB. The file is deleted and the upload id is spent.
403insufficient_scopeThe key lacks applications:write.
403write_requires_api_keyThe credential is a Sign in with Tahoe token, or is not tied to a Tahoe user.
404not_foundThe job does not exist in the key’s workspace, or its handle is malformed.
409job_not_accepting_applicationsThe job is not published, or its accept applications switch is off.
409applicant_suppressedThe person was erased or asked Tahoe to stop processing their data. Nothing was created. Stop sending this person.
409applicant_unsubscribedThe person unsubscribed in this workspace. No application was created. A person who is already on this job is still returned with 200.
409candidate_consent_requiredThe job’s form has a required consent box that only the candidate can tick. The message names the box.
409upload_already_usedThe upload was already attached to an application. Upload the file again.
409upload_not_completedNo file has arrived at the upload URL yet. Upload it, then retry with the same id.
409application_conflictA concurrent request created the same application and it could not be read back. Retry.
409idempotency_key_reusedThe same key was used with a different body.
409idempotency_in_flightThe first request with this key is still running. Retry shortly.
429too_many_pending_uploadsPOST /uploads only. Wait for Retry-After (600 seconds).
429suppression_check_unavailableTahoe could not read its suppression list. Nothing was created. Retry shortly.
429rate_limit_exceededWait for Retry-After.