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
- If the candidate has a resume, call
POST /uploadsandPUTthe file to the URL it returns. - Call
POST /applicationswith the job, the candidate and, if you uploaded a file, itsupload_idasresume_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.
| Field | Type | Notes |
|---|---|---|
content_type | string, required | One of application/pdf, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/msword, application/rtf, text/rtf or text/plain. |
size_bytes | integer, required | The exact length of the file, from 1 to 5,242,880 (5 MB). |
filename | string | Optional. 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>. |
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"
}'{
"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.
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). Theupload_idcan be named inPOST /applicationsfor one hour (expires_at), and only once. - The upload URL points at storage, not at the Tahoe API. Do not send your
Authorizationheader with thePUT. - 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_uploadswithRetry-After: 600. - With an
Idempotency-Key, a repeated call returns the first response, including the same URL, withIdempotent-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.
| Field | Type | Notes |
|---|---|---|
job_id | handle, required | The job. It must be published and accepting applications. |
candidate | object, required | first_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_id | string | Optional. The upload_id from POST /uploads, once the file has been uploaded. |
source | object, required | system 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. |
answers | array | Up 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_id | handle | Optional. 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. |
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"
}'{
"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:
- If
source.external_idis given, and an application on this job was already created with the samesource.systemandexternal_id. - 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.
{
"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
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_request | Malformed body, unknown field, bad email, bad source.system, a reserved name or an over-long value. |
| 400 | invalid_upload | POST /uploads only. The file type or size is not accepted. |
| 400 | invalid_stage | The stage is not on this job, is a final stage, or its handle is malformed. |
| 400 | unknown_answer_field | An answer names a question the job’s form does not have. param is answers[i].field_id. |
| 400 | duplicate_answer_field | The same question was answered twice. |
| 400 | invalid_answer_value | The value has the wrong shape, or is not one of the question’s options. |
| 400 | answer_field_not_accepted | The question is an equal-opportunity question, a consent box, a section header or the resume field. |
| 400 | answer_field_reserved | The question is the name, email, phone or LinkedIn field. Send those in candidate. |
| 400 | missing_required_answers | The 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. |
| 400 | upload_not_found | resume_upload_id is unknown, expired, or belongs to another workspace. |
| 400 | resume_rejected | The uploaded file is empty or larger than 5 MB. The file is deleted and the upload id is spent. |
| 403 | insufficient_scope | The key lacks applications:write. |
| 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 | The job does not exist in the key’s workspace, or its handle is malformed. |
| 409 | job_not_accepting_applications | The job is not published, or its accept applications switch is off. |
| 409 | applicant_suppressed | The person was erased or asked Tahoe to stop processing their data. Nothing was created. Stop sending this person. |
| 409 | applicant_unsubscribed | The person unsubscribed in this workspace. No application was created. A person who is already on this job is still returned with 200. |
| 409 | candidate_consent_required | The job’s form has a required consent box that only the candidate can tick. The message names the box. |
| 409 | upload_already_used | The upload was already attached to an application. Upload the file again. |
| 409 | upload_not_completed | No file has arrived at the upload URL yet. Upload it, then retry with the same id. |
| 409 | application_conflict | A concurrent request created the same application and it could not be read back. 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. |
| 429 | too_many_pending_uploads | POST /uploads only. Wait for Retry-After (600 seconds). |
| 429 | suppression_check_unavailable | Tahoe could not read its suppression list. Nothing was created. Retry shortly. |
| 429 | rate_limit_exceeded | Wait for Retry-After. |